diff --git a/api/clients/__init__.py b/api/clients/__init__.py new file mode 100644 index 00000000000000..29327160759a76 --- /dev/null +++ b/api/clients/__init__.py @@ -0,0 +1 @@ +"""External service client packages.""" diff --git a/api/clients/agent_backend/__init__.py b/api/clients/agent_backend/__init__.py new file mode 100644 index 00000000000000..2e3777f61b3ef0 --- /dev/null +++ b/api/clients/agent_backend/__init__.py @@ -0,0 +1,74 @@ +"""API-side integration boundary for the Dify Agent backend. + +Public wire DTOs come from ``dify_agent.protocol``. This package only contains +API adapters: request building from Dify product concepts, a thin client wrapper, +event adaptation for future workflow integration, and deterministic fakes. +""" + +from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient +from clients.agent_backend.errors import ( + AgentBackendError, + AgentBackendHTTPError, + AgentBackendRequestBuildError, + AgentBackendRunFailedError, + AgentBackendStreamError, + AgentBackendTransportError, + AgentBackendValidationError, +) +from clients.agent_backend.event_adapter import ( + AgentBackendInternalEvent, + AgentBackendInternalEventType, + AgentBackendRunCancelledInternalEvent, + AgentBackendRunEventAdapter, + AgentBackendRunFailedInternalEvent, + AgentBackendRunPausedInternalEvent, + AgentBackendRunStartedInternalEvent, + AgentBackendRunSucceededInternalEvent, + AgentBackendStreamInternalEvent, +) +from clients.agent_backend.factory import create_agent_backend_run_client +from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario +from clients.agent_backend.request_builder import ( + AGENT_SOUL_PROMPT_LAYER_ID, + DIFY_PLUGIN_CONTEXT_LAYER_ID, + WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, + WORKFLOW_USER_PROMPT_LAYER_ID, + AgentBackendModelConfig, + AgentBackendOutputConfig, + AgentBackendRunRequestBuilder, + AgentBackendWorkflowNodeRunInput, + redact_for_agent_backend_log, +) + +__all__ = [ + "AGENT_SOUL_PROMPT_LAYER_ID", + "DIFY_PLUGIN_CONTEXT_LAYER_ID", + "WORKFLOW_NODE_JOB_PROMPT_LAYER_ID", + "WORKFLOW_USER_PROMPT_LAYER_ID", + "AgentBackendError", + "AgentBackendHTTPError", + "AgentBackendInternalEvent", + "AgentBackendInternalEventType", + "AgentBackendModelConfig", + "AgentBackendOutputConfig", + "AgentBackendRequestBuildError", + "AgentBackendRunCancelledInternalEvent", + "AgentBackendRunClient", + "AgentBackendRunEventAdapter", + "AgentBackendRunFailedError", + "AgentBackendRunFailedInternalEvent", + "AgentBackendRunPausedInternalEvent", + "AgentBackendRunRequestBuilder", + "AgentBackendRunStartedInternalEvent", + "AgentBackendRunSucceededInternalEvent", + "AgentBackendStreamError", + "AgentBackendStreamInternalEvent", + "AgentBackendTransportError", + "AgentBackendValidationError", + "AgentBackendWorkflowNodeRunInput", + "DifyAgentBackendRunClient", + "FakeAgentBackendRunClient", + "FakeAgentBackendScenario", + "create_agent_backend_run_client", + "redact_for_agent_backend_log", +] diff --git a/api/clients/agent_backend/client.py b/api/clients/agent_backend/client.py new file mode 100644 index 00000000000000..2b2cfbdf955b6a --- /dev/null +++ b/api/clients/agent_backend/client.py @@ -0,0 +1,130 @@ +"""Synchronous API-side wrapper around the public ``dify-agent`` client. + +``dify-agent`` owns the cross-service DTOs and HTTP/SSE implementation. The API +backend keeps this thin wrapper so workflow code depends on a local protocol, +gets API-native errors, and can use a deterministic fake in tests without +creating another wire contract. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Protocol + +from dify_agent.client import ( + DifyAgentClientError, + DifyAgentHTTPError, + DifyAgentStreamError, + DifyAgentTimeoutError, + DifyAgentValidationError, +) +from dify_agent.protocol import ( + CancelRunRequest, + CancelRunResponse, + CreateRunRequest, + CreateRunResponse, + RunEvent, + RunStatusResponse, +) + +from clients.agent_backend.errors import ( + AgentBackendError, + AgentBackendHTTPError, + AgentBackendStreamError, + AgentBackendTransportError, + AgentBackendValidationError, +) + + +class AgentBackendRunClient(Protocol): + """Local boundary used by API workflow integrations to run Agent backend jobs.""" + + def create_run(self, request: CreateRunRequest) -> CreateRunResponse: + """Create one Agent backend run and return its accepted status.""" + + def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse: + """Request explicit cancellation for one Agent backend run.""" + + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + """Yield public ``dify-agent`` run events in stream order.""" + + def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse: + """Wait for a run to reach a terminal status and return that status.""" + + +class _DifyAgentSyncClient(Protocol): + """Subset of ``dify_agent.client.Client`` used by the API wrapper.""" + + def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse: + """Create one run synchronously.""" + + def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse: + """Cancel one run synchronously.""" + + def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + """Stream run events synchronously.""" + + def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse: + """Wait for terminal run status synchronously.""" + + +class DifyAgentBackendRunClient: + """Adapter from API sync call sites to ``dify_agent.client.Client`` sync methods.""" + + client: _DifyAgentSyncClient + + def __init__(self, client: _DifyAgentSyncClient) -> None: + self.client = client + + def create_run(self, request: CreateRunRequest) -> CreateRunResponse: + """Create one run through ``POST /runs`` and normalize client exceptions.""" + try: + return self.client.create_run_sync(request) + except Exception as exc: + raise _normalize_dify_agent_error(exc) from exc + + def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse: + """Cancel one run through ``POST /runs/{run_id}/cancel`` and normalize exceptions.""" + try: + return self.client.cancel_run_sync(run_id, request=request) + except Exception as exc: + raise _normalize_dify_agent_error(exc) from exc + + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + """Stream run events from ``/events/sse`` with the wrapped client's reconnect policy.""" + try: + yield from self.client.stream_events_sync(run_id, after=after) + except Exception as exc: + raise _normalize_dify_agent_error(exc) from exc + + def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse: + """Poll run status until terminal state and normalize client exceptions.""" + try: + return self.client.wait_run_sync(run_id, timeout_seconds=timeout_seconds) + except Exception as exc: + raise _normalize_dify_agent_error(exc) from exc + + +def _normalize_dify_agent_error(exc: Exception) -> AgentBackendError: + """Map public ``dify-agent`` client errors to API-side integration errors.""" + match exc: + case DifyAgentValidationError() as error: + return AgentBackendValidationError( + "Agent backend request or response validation failed", detail=error.detail + ) + case DifyAgentHTTPError() as error: + return AgentBackendHTTPError( + f"Agent backend HTTP {error.status_code}", + status_code=error.status_code, + detail=error.detail, + ) + case DifyAgentTimeoutError() as error: + return AgentBackendTransportError(str(error)) + case DifyAgentStreamError() as error: + return AgentBackendStreamError(str(error)) + case DifyAgentClientError() as error: + return AgentBackendTransportError(str(error)) + case AgentBackendError() as error: + return error + case _: + return AgentBackendTransportError(str(exc) or type(exc).__name__) diff --git a/api/clients/agent_backend/errors.py b/api/clients/agent_backend/errors.py new file mode 100644 index 00000000000000..ee88c65fa88c8c --- /dev/null +++ b/api/clients/agent_backend/errors.py @@ -0,0 +1,61 @@ +"""API-side errors for the Dify Agent backend integration. + +The wire protocol and low-level HTTP behaviour are owned by ``dify-agent``. +This module only normalizes those client errors into the API backend's boundary +so workflow/node code does not depend directly on transport-specific exception +classes. +""" + +from __future__ import annotations + +from typing import Any + + +class AgentBackendError(Exception): + """Base error for API-side Agent backend integration failures.""" + + +class AgentBackendRequestBuildError(AgentBackendError): + """Raised when Dify product/workflow state cannot be mapped to a run request.""" + + +class AgentBackendTransportError(AgentBackendError): + """Raised for timeout or request-level failures talking to Agent backend.""" + + +class AgentBackendHTTPError(AgentBackendTransportError): + """Raised for Agent backend HTTP errors after status/detail normalization.""" + + status_code: int + detail: object + + def __init__(self, message: str, *, status_code: int, detail: object) -> None: + self.status_code = status_code + self.detail = detail + super().__init__(message) + + +class AgentBackendValidationError(AgentBackendError): + """Raised for local request validation or Agent backend 422 responses.""" + + detail: object + + def __init__(self, message: str, *, detail: object) -> None: + self.detail = detail + super().__init__(message) + + +class AgentBackendStreamError(AgentBackendError): + """Raised when an Agent backend event stream is malformed or exhausted.""" + + +class AgentBackendRunFailedError(AgentBackendError): + """Raised by callers that choose to translate a terminal failed run into an exception.""" + + run_id: str + detail: Any + + def __init__(self, run_id: str, detail: Any) -> None: + self.run_id = run_id + self.detail = detail + super().__init__(f"Agent backend run failed: {run_id}") diff --git a/api/clients/agent_backend/event_adapter.py b/api/clients/agent_backend/event_adapter.py new file mode 100644 index 00000000000000..02b30e6c6b33f8 --- /dev/null +++ b/api/clients/agent_backend/event_adapter.py @@ -0,0 +1,167 @@ +"""Adapt public ``dify-agent`` run events into API-internal event semantics. + +The adapter does not define a new cross-service event contract. It consumes +``dify_agent.protocol.RunEvent`` and produces small API-internal models that the +future workflow Agent Node can map to Graphon/AppQueue events in phase 3. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated, Literal, cast + +from agenton.compositor import CompositorSessionSnapshot +from dify_agent.protocol import ( + PydanticAIStreamRunEvent, + RunCancelledEvent, + RunEvent, + RunFailedEvent, + RunPausedEvent, + RunStartedEvent, + RunSucceededEvent, +) +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter + +_EVENT_DATA_ADAPTER = TypeAdapter(object) + + +class AgentBackendInternalEventType(StrEnum): + """API-only event labels used before Graphon/AppQueue integration.""" + + RUN_STARTED = "run_started" + STREAM_EVENT = "stream_event" + RUN_PAUSED = "run_paused" + RUN_SUCCEEDED = "run_succeeded" + RUN_FAILED = "run_failed" + RUN_CANCELLED = "run_cancelled" + + +class AgentBackendInternalEventBase(BaseModel): + """Common fields preserved from public Dify Agent run events.""" + + run_id: str + source_event_id: str | None = None + + model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + +class AgentBackendRunStartedInternalEvent(AgentBackendInternalEventBase): + """API-internal marker for a started Agent backend run.""" + + type: Literal[AgentBackendInternalEventType.RUN_STARTED] = AgentBackendInternalEventType.RUN_STARTED + + +class AgentBackendStreamInternalEvent(AgentBackendInternalEventBase): + """API-internal wrapper for one pydantic-ai stream event payload.""" + + type: Literal[AgentBackendInternalEventType.STREAM_EVENT] = AgentBackendInternalEventType.STREAM_EVENT + event_kind: str | None = None + data: JsonValue + + +class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase): + """API-internal terminal success event carrying final output and session state.""" + + type: Literal[AgentBackendInternalEventType.RUN_SUCCEEDED] = AgentBackendInternalEventType.RUN_SUCCEEDED + output: JsonValue + session_snapshot: CompositorSessionSnapshot + + +class AgentBackendRunPausedInternalEvent(AgentBackendInternalEventBase): + """API-internal resumable pause event for human handoff and Babysit flows.""" + + type: Literal[AgentBackendInternalEventType.RUN_PAUSED] = AgentBackendInternalEventType.RUN_PAUSED + reason: str + message: str | None = None + session_snapshot: CompositorSessionSnapshot | None = None + + +class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase): + """API-internal terminal failure event carrying the backend-safe error text.""" + + type: Literal[AgentBackendInternalEventType.RUN_FAILED] = AgentBackendInternalEventType.RUN_FAILED + error: str + reason: str | None = None + + +class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase): + """API-internal terminal cancellation event.""" + + type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED + reason: str | None = None + message: str | None = None + + +type AgentBackendInternalEvent = Annotated[ + AgentBackendRunStartedInternalEvent + | AgentBackendStreamInternalEvent + | AgentBackendRunPausedInternalEvent + | AgentBackendRunSucceededInternalEvent + | AgentBackendRunFailedInternalEvent + | AgentBackendRunCancelledInternalEvent, + Field(discriminator="type"), +] + + +class AgentBackendRunEventAdapter: + """Maps public ``dify-agent`` event variants to API-internal event variants.""" + + def adapt(self, event: RunEvent) -> list[AgentBackendInternalEvent]: + """Return zero or more API-internal events derived from one public run event.""" + match event: + case RunStartedEvent(): + return [ + AgentBackendRunStartedInternalEvent( + run_id=event.run_id, + source_event_id=event.id, + ) + ] + case PydanticAIStreamRunEvent(): + data = cast(JsonValue, _EVENT_DATA_ADAPTER.dump_python(event.data, mode="json")) + event_kind = data.get("event_kind") if isinstance(data, dict) else None + return [ + AgentBackendStreamInternalEvent( + run_id=event.run_id, + source_event_id=event.id, + event_kind=event_kind if isinstance(event_kind, str) else None, + data=data, + ) + ] + case RunSucceededEvent(): + return [ + AgentBackendRunSucceededInternalEvent( + run_id=event.run_id, + source_event_id=event.id, + output=event.data.output, + session_snapshot=event.data.session_snapshot, + ) + ] + case RunPausedEvent(): + return [ + AgentBackendRunPausedInternalEvent( + run_id=event.run_id, + source_event_id=event.id, + reason=event.data.reason, + message=event.data.message, + session_snapshot=event.data.session_snapshot, + ) + ] + case RunFailedEvent(): + return [ + AgentBackendRunFailedInternalEvent( + run_id=event.run_id, + source_event_id=event.id, + error=event.data.error, + reason=event.data.reason, + ) + ] + case RunCancelledEvent(): + return [ + AgentBackendRunCancelledInternalEvent( + run_id=event.run_id, + source_event_id=event.id, + reason=event.data.reason, + message=event.data.message, + ) + ] + raise TypeError(f"unsupported agent backend run event: {type(event).__name__}") diff --git a/api/clients/agent_backend/factory.py b/api/clients/agent_backend/factory.py new file mode 100644 index 00000000000000..133eb42b28d8c7 --- /dev/null +++ b/api/clients/agent_backend/factory.py @@ -0,0 +1,22 @@ +"""Factories for API-side Agent backend clients.""" + +from __future__ import annotations + +from dify_agent.client import Client + +from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient +from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario + + +def create_agent_backend_run_client( + *, + base_url: str | None = None, + use_fake: bool = False, + fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS, +) -> AgentBackendRunClient: + """Create the API-side run client without hiding the ``dify-agent`` protocol.""" + if use_fake: + return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario)) + if base_url is None: + raise ValueError("base_url is required when creating a real Agent backend client") + return DifyAgentBackendRunClient(Client(base_url=base_url)) diff --git a/api/clients/agent_backend/fake_client.py b/api/clients/agent_backend/fake_client.py new file mode 100644 index 00000000000000..6414ddc7b55958 --- /dev/null +++ b/api/clients/agent_backend/fake_client.py @@ -0,0 +1,117 @@ +"""Deterministic fake Agent backend client using public ``dify-agent`` events. + +Tests should exercise the same ``RunEvent`` DTOs as the real HTTP client. This +fake therefore replaces the previous custom mock protocol instead of emulating a +separate ``agent-backend.v1`` event stream. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import UTC, datetime +from enum import StrEnum + +from agenton.compositor import CompositorSessionSnapshot +from dify_agent.protocol import ( + CancelRunRequest, + CancelRunResponse, + CreateRunRequest, + CreateRunResponse, + RunEvent, + RunFailedEvent, + RunFailedEventData, + RunStartedEvent, + RunStatusResponse, + RunSucceededEvent, + RunSucceededEventData, +) + +_FIXED_TIME = datetime(2026, 1, 1, tzinfo=UTC) + + +class FakeAgentBackendScenario(StrEnum): + """Deterministic fake scenarios for API-side integration tests.""" + + SUCCESS = "success" + FAILED = "failed" + + +class FakeAgentBackendRunClient: + """In-memory implementation of ``AgentBackendRunClient`` for unit tests.""" + + scenario: FakeAgentBackendScenario + run_id: str + request: CreateRunRequest | None + + def __init__( + self, + *, + scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS, + run_id: str = "fake-run-1", + ) -> None: + self.scenario = scenario + self.run_id = run_id + self.request = None + + def create_run(self, request: CreateRunRequest) -> CreateRunResponse: + """Record the request and return a deterministic accepted response.""" + self.request = request + return CreateRunResponse(run_id=self.run_id, status="running") + + def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse: + """Return a deterministic cancellation response.""" + del request + return CancelRunResponse(run_id=run_id, status="cancelled") + + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + """Yield the deterministic public ``RunEvent`` sequence for ``run_id``.""" + for event in self._events(run_id): + if after is not None and event.id is not None and event.id <= after: + continue + yield event + + def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse: + """Return a deterministic terminal status; timeout is accepted for protocol parity.""" + del timeout_seconds + match self.scenario: + case FakeAgentBackendScenario.SUCCESS: + return RunStatusResponse( + run_id=run_id, + status="succeeded", + created_at=_FIXED_TIME, + updated_at=_FIXED_TIME, + ) + case FakeAgentBackendScenario.FAILED: + return RunStatusResponse( + run_id=run_id, + status="failed", + created_at=_FIXED_TIME, + updated_at=_FIXED_TIME, + error="fake failure", + ) + + def _events(self, run_id: str) -> tuple[RunEvent, ...]: + match self.scenario: + case FakeAgentBackendScenario.SUCCESS: + return ( + RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME), + RunSucceededEvent( + id="2-0", + run_id=run_id, + created_at=_FIXED_TIME, + data=RunSucceededEventData( + output={"text": "hello agent"}, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ), + ) + case FakeAgentBackendScenario.FAILED: + return ( + RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME), + RunFailedEvent( + id="2-0", + run_id=run_id, + created_at=_FIXED_TIME, + data=RunFailedEventData(error="fake failure", reason="unit_test"), + ), + ) diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py new file mode 100644 index 00000000000000..41b9ce059d5386 --- /dev/null +++ b/api/clients/agent_backend/request_builder.py @@ -0,0 +1,192 @@ +"""Build ``dify-agent`` run requests from API-side product concepts. + +This module is intentionally an adapter, not a wire DTO package. The emitted +object is always ``dify_agent.protocol.CreateRunRequest`` so the Agent backend +protocol has a single owner. API-only context such as Agent Soul vs workflow job +prompt is preserved in layer names and metadata until the dedicated product +schemas land in later phases. +""" + +from __future__ import annotations + +from typing import ClassVar + +from agenton.compositor import CompositorSessionSnapshot +from agenton.layers import ExitIntent +from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig +from dify_agent.layers.dify_plugin import ( + DIFY_PLUGIN_LAYER_TYPE_ID, + DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + DifyPluginCredentialValue, + DifyPluginLayerConfig, + DifyPluginLLMLayerConfig, +) +from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig +from dify_agent.protocol import ( + DIFY_AGENT_MODEL_LAYER_ID, + DIFY_AGENT_OUTPUT_LAYER_ID, + CreateRunRequest, + ExecutionContext, + LayerExitSignals, + RunComposition, + RunLayerSpec, + RunPurpose, +) +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator + +AGENT_SOUL_PROMPT_LAYER_ID = "agent_soul_prompt" +WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt" +WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt" +DIFY_PLUGIN_CONTEXT_LAYER_ID = "plugin" + + +class AgentBackendModelConfig(BaseModel): + """API-side model/plugin selection before it is converted to Dify Agent layers.""" + + tenant_id: str + plugin_id: str + model_provider: str + model: str + user_id: str | None = None + credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentBackendOutputConfig(BaseModel): + """API-side structured output declaration for the conventional output layer.""" + + json_schema: dict[str, JsonValue] + name: str = "final_result" + description: str | None = None + strict: bool | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentBackendWorkflowNodeRunInput(BaseModel): + """Inputs needed to build the first workflow-node-oriented Agent backend run request.""" + + model: AgentBackendModelConfig + execution_context: ExecutionContext + workflow_node_job_prompt: str + user_prompt: str + agent_soul_prompt: str | None = None + purpose: RunPurpose = "workflow_node" + idempotency_key: str | None = None + output: AgentBackendOutputConfig | None = None + session_snapshot: CompositorSessionSnapshot | None = None + suspend_on_exit: bool = False + metadata: dict[str, JsonValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + @field_validator("workflow_node_job_prompt", "user_prompt") + @classmethod + def _reject_blank_prompt(cls, value: str) -> str: + if not value.strip(): + raise ValueError("prompt must not be blank") + return value + + +class AgentBackendRunRequestBuilder: + """Converts API product state into the public ``dify-agent`` run protocol.""" + + def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest: + """Build a workflow Agent Node run request without defining another wire schema.""" + layers: list[RunLayerSpec] = [] + if run_input.agent_soul_prompt: + layers.append( + RunLayerSpec( + name=AGENT_SOUL_PROMPT_LAYER_ID, + type=PLAIN_PROMPT_LAYER_TYPE_ID, + metadata={**run_input.metadata, "origin": "agent_soul"}, + config=PromptLayerConfig(prefix=run_input.agent_soul_prompt), + ) + ) + + layers.extend( + [ + RunLayerSpec( + name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, + type=PLAIN_PROMPT_LAYER_TYPE_ID, + metadata={**run_input.metadata, "origin": "workflow_node_job"}, + config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt), + ), + RunLayerSpec( + name=WORKFLOW_USER_PROMPT_LAYER_ID, + type=PLAIN_PROMPT_LAYER_TYPE_ID, + metadata={**run_input.metadata, "origin": "workflow_user_prompt"}, + config=PromptLayerConfig(user=run_input.user_prompt), + ), + RunLayerSpec( + name=DIFY_PLUGIN_CONTEXT_LAYER_ID, + type=DIFY_PLUGIN_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=DifyPluginLayerConfig( + tenant_id=run_input.model.tenant_id, + plugin_id=run_input.model.plugin_id, + user_id=run_input.model.user_id, + ), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + deps={"plugin": DIFY_PLUGIN_CONTEXT_LAYER_ID}, + metadata=run_input.metadata, + config=DifyPluginLLMLayerConfig( + model_provider=run_input.model.model_provider, + model=run_input.model.model, + credentials=run_input.model.credentials, + ), + ), + ] + ) + + if run_input.output is not None: + layers.append( + RunLayerSpec( + name=DIFY_AGENT_OUTPUT_LAYER_ID, + type=DIFY_OUTPUT_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=DifyOutputLayerConfig( + json_schema=run_input.output.json_schema, + name=run_input.output.name, + description=run_input.output.description, + strict=run_input.output.strict, + ), + ) + ) + + return CreateRunRequest( + composition=RunComposition(layers=layers), + execution_context=run_input.execution_context, + purpose=run_input.purpose, + idempotency_key=run_input.idempotency_key, + metadata=run_input.metadata, + session_snapshot=run_input.session_snapshot, + on_exit=LayerExitSignals( + default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE, + ), + ) + + +_SENSITIVE_KEY_PARTS = ("secret", "credential", "token", "password", "api_key") + + +def redact_for_agent_backend_log(value: object) -> object: + """Return a JSON-like copy with credential-bearing keys redacted for logs/tests.""" + if isinstance(value, BaseModel): + return redact_for_agent_backend_log(value.model_dump(mode="json", warnings=False)) + if isinstance(value, dict): + redacted: dict[object, object] = {} + for key, item in value.items(): + key_text = str(key).lower() + if any(part in key_text for part in _SENSITIVE_KEY_PARTS): + redacted[key] = "[REDACTED]" + else: + redacted[key] = redact_for_agent_backend_log(item) + return redacted + if isinstance(value, list): + return [redact_for_agent_backend_log(item) for item in value] + return value diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index f5aeb17ba2235b..102d7c1a886f40 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -44,6 +44,8 @@ spec, version, ) +from .agent import composer as agent_composer +from .agent import roster as agent_roster # Import app controllers from .app import ( @@ -143,7 +145,9 @@ "activate", "advanced_prompt_template", "agent", + "agent_composer", "agent_providers", + "agent_roster", "annotation", "api", "apikey", diff --git a/api/controllers/console/agent/__init__.py b/api/controllers/console/agent/__init__.py new file mode 100644 index 00000000000000..88b955dcbaf397 --- /dev/null +++ b/api/controllers/console/agent/__init__.py @@ -0,0 +1,3 @@ +from . import composer, roster + +__all__ = ["composer", "roster"] diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py new file mode 100644 index 00000000000000..d716ab9fd2b1d7 --- /dev/null +++ b/api/controllers/console/agent/composer.py @@ -0,0 +1,153 @@ +from flask_restx import Resource + +from controllers.common.schema import register_schema_models +from controllers.console import console_ns +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required +from libs.login import current_account_with_tenant, login_required +from models.model import AppMode +from services.agent.composer_service import AgentComposerService +from services.agent.composer_validator import ComposerConfigValidator +from services.entities.agent_entities import ComposerSavePayload + +register_schema_models(console_ns, ComposerSavePayload) + + +@console_ns.route("/apps//workflows/draft/nodes//agent-composer") +class WorkflowAgentComposerApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + def get(self, app_model, node_id: str): + _, tenant_id = current_account_with_tenant() + return AgentComposerService.load_workflow_composer( + tenant_id=tenant_id, + app_id=app_model.id, + node_id=node_id, + ) + + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + def put(self, app_model, node_id: str): + account, tenant_id = current_account_with_tenant() + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + return AgentComposerService.save_workflow_composer( + tenant_id=tenant_id, + app_id=app_model.id, + node_id=node_id, + account_id=account.id, + payload=payload, + ) + + +@console_ns.route("/apps//workflows/draft/nodes//agent-composer/validate") +class WorkflowAgentComposerValidateApi(Resource): + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + def post(self, app_model, node_id: str): + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + ComposerConfigValidator.validate_save_payload(payload) + return {"result": "success", "errors": []} + + +@console_ns.route("/apps//workflows/draft/nodes//agent-composer/candidates") +class WorkflowAgentComposerCandidatesApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + def get(self, app_model, node_id: str): + return AgentComposerService.get_workflow_candidates(app_id=app_model.id) + + +@console_ns.route("/apps//workflows/draft/nodes//agent-composer/impact") +class WorkflowAgentComposerImpactApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + def post(self, app_model, node_id: str): + _, tenant_id = current_account_with_tenant() + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None + if not current_snapshot_id: + return {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []} + return AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id) + + +@console_ns.route("/apps//workflows/draft/nodes//agent-composer/save-to-roster") +class WorkflowAgentComposerSaveToRosterApi(Resource): + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + def post(self, app_model, node_id: str): + account, tenant_id = current_account_with_tenant() + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + return AgentComposerService.save_workflow_composer( + tenant_id=tenant_id, + app_id=app_model.id, + node_id=node_id, + account_id=account.id, + payload=payload, + ) + + +@console_ns.route("/apps//agent-composer") +class AgentAppComposerApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model() + def get(self, app_model): + _, tenant_id = current_account_with_tenant() + return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id) + + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @get_app_model() + def put(self, app_model): + account, tenant_id = current_account_with_tenant() + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + return AgentComposerService.save_agent_app_composer( + tenant_id=tenant_id, + app_id=app_model.id, + account_id=account.id, + payload=payload, + ) + + +@console_ns.route("/apps//agent-composer/validate") +class AgentAppComposerValidateApi(Resource): + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model() + def post(self, app_model): + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + ComposerConfigValidator.validate_save_payload(payload) + return {"result": "success", "errors": []} + + +@console_ns.route("/apps//agent-composer/candidates") +class AgentAppComposerCandidatesApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model() + def get(self, app_model): + return AgentComposerService.get_agent_app_candidates(app_id=app_model.id) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py new file mode 100644 index 00000000000000..3334f1bb2d1730 --- /dev/null +++ b/api/controllers/console/agent/roster.py @@ -0,0 +1,130 @@ +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, Field + +from controllers.common.schema import register_schema_models +from controllers.console import console_ns +from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required +from extensions.ext_database import db +from libs.login import current_account_with_tenant, login_required +from services.agent.roster_service import AgentRosterService +from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery + + +class AgentInviteOptionsQuery(RosterListQuery): + app_id: str | None = Field(default=None, description="Workflow app id for in-current-workflow markers") + + +class AgentIdPath(BaseModel): + agent_id: str + + +register_schema_models( + console_ns, + AgentInviteOptionsQuery, + AgentIdPath, + RosterAgentCreatePayload, + RosterAgentUpdatePayload, + RosterListQuery, +) + + +def _agent_roster_service() -> AgentRosterService: + return AgentRosterService(db.session) + + +@console_ns.route("/agents") +class AgentRosterListApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + _, tenant_id = current_account_with_tenant() + query = RosterListQuery.model_validate(request.args.to_dict(flat=True)) + return _agent_roster_service().list_roster_agents( + tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword + ) + + @console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def post(self): + account, tenant_id = current_account_with_tenant() + payload = RosterAgentCreatePayload.model_validate(console_ns.payload or {}) + service = _agent_roster_service() + agent = service.create_roster_agent(tenant_id=tenant_id, account_id=account.id, payload=payload) + return service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id), 201 + + +@console_ns.route("/agents/invite-options") +class AgentInviteOptionsApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + _, tenant_id = current_account_with_tenant() + query = AgentInviteOptionsQuery.model_validate(request.args.to_dict(flat=True)) + return _agent_roster_service().list_invite_options( + tenant_id=tenant_id, + page=query.page, + limit=query.limit, + keyword=query.keyword, + app_id=query.app_id, + ) + + +@console_ns.route("/agents/") +class AgentRosterDetailApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, agent_id): + _, tenant_id = current_account_with_tenant() + return _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)) + + @console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def patch(self, agent_id): + account, tenant_id = current_account_with_tenant() + payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {}) + return _agent_roster_service().update_roster_agent( + tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload + ) + + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def delete(self, agent_id): + account, tenant_id = current_account_with_tenant() + _agent_roster_service().archive_roster_agent(tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id) + return "", 204 + + +@console_ns.route("/agents//versions") +class AgentRosterVersionsApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, agent_id): + _, tenant_id = current_account_with_tenant() + return {"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))} + + +@console_ns.route("/agents//versions/") +class AgentRosterVersionDetailApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, agent_id, version_id): + _, tenant_id = current_account_with_tenant() + return _agent_roster_service().get_agent_version_detail( + tenant_id=tenant_id, + agent_id=str(agent_id), + version_id=str(version_id), + ) diff --git a/api/migrations/versions/2026_05_18_1330-c6a9f4b12d3e_add_agent_domain_models.py b/api/migrations/versions/2026_05_18_1330-c6a9f4b12d3e_add_agent_domain_models.py new file mode 100644 index 00000000000000..676fd7e4a4a60c --- /dev/null +++ b/api/migrations/versions/2026_05_18_1330-c6a9f4b12d3e_add_agent_domain_models.py @@ -0,0 +1,162 @@ +"""add agent domain models + +Revision ID: c6a9f4b12d3e +Revises: a4f2d8c9b731 +Create Date: 2026-05-18 13:30:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "c6a9f4b12d3e" +down_revision = "a4f2d8c9b731" +branch_labels = None +depends_on = None + + +def _is_pg(conn) -> bool: + return conn.dialect.name == "postgresql" + + +def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column: + kwargs = {"nullable": nullable, "primary_key": primary_key} + if primary_key and _is_pg(op.get_bind()): + kwargs["server_default"] = sa.text("uuidv7()") + return sa.Column(name, models.types.StringUUID(), **kwargs) + + +def upgrade(): + op.create_table( + "agents", + _uuid_column("id", primary_key=True), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", models.types.LongText(), nullable=False), + sa.Column("icon_type", sa.String(length=32), nullable=True), + sa.Column( + "icon", + sa.String(length=255), + nullable=True, + comment="Icon payload interpreted by icon_type: emoji character, image file id, or external URL.", + ), + sa.Column("icon_background", sa.String(length=255), nullable=True), + sa.Column("agent_kind", sa.String(length=32), server_default=sa.text("'dify_agent'"), nullable=False), + sa.Column("scope", sa.String(length=32), nullable=False), + sa.Column("source", sa.String(length=32), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=True), + sa.Column("workflow_id", models.types.StringUUID(), nullable=True), + sa.Column("workflow_node_id", sa.String(length=255), nullable=True), + sa.Column("active_config_snapshot_id", models.types.StringUUID(), nullable=True), + sa.Column("status", sa.String(length=32), server_default=sa.text("'active'"), nullable=False), + sa.Column( + "roster_unique_name", + sa.String(length=255), + sa.Computed("CASE WHEN scope = 'roster' AND status = 'active' THEN name ELSE NULL END"), + nullable=True, + ), + sa.Column("created_by", models.types.StringUUID(), nullable=True), + sa.Column("updated_by", models.types.StringUUID(), nullable=True), + sa.Column("archived_by", models.types.StringUUID(), nullable=True), + sa.Column("archived_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("agent_pkey")), + sa.UniqueConstraint("tenant_id", "roster_unique_name", name=op.f("agents_tenant_id_key")), + ) + op.create_index("agent_tenant_updated_at_idx", "agents", ["tenant_id", "updated_at"]) + op.create_index("agent_tenant_scope_idx", "agents", ["tenant_id", "scope"]) + op.create_index("agent_tenant_workflow_id_idx", "agents", ["tenant_id", "workflow_id"]) + op.create_index("agent_tenant_app_id_idx", "agents", ["tenant_id", "app_id"]) + op.create_index("agent_active_config_snapshot_id_idx", "agents", ["active_config_snapshot_id"]) + + op.create_table( + "agent_config_snapshots", + _uuid_column("id", primary_key=True), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("agent_id", models.types.StringUUID(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column( + "config_snapshot", + models.types.LongText(), + nullable=False, + comment="Serialized services.entities.agent_entities.AgentSoulConfig JSON.", + ), + sa.Column("summary", models.types.LongText(), nullable=True), + sa.Column("version_note", models.types.LongText(), nullable=True), + sa.Column("created_by", models.types.StringUUID(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("agent_config_snapshot_pkey")), + sa.UniqueConstraint("agent_id", "version", name=op.f("agent_config_snapshot_agent_version_unique")), + ) + op.create_index( + "agent_config_snapshot_tenant_agent_created_at_idx", + "agent_config_snapshots", + ["tenant_id", "agent_id", "created_at"], + ) + op.create_index( + "agent_config_snapshot_tenant_created_at_idx", + "agent_config_snapshots", + ["tenant_id", "created_at"], + ) + + op.create_table( + "workflow_agent_node_bindings", + _uuid_column("id", primary_key=True), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("workflow_id", models.types.StringUUID(), nullable=False), + sa.Column("node_id", sa.String(length=255), nullable=False), + sa.Column("binding_type", sa.String(length=32), nullable=False), + sa.Column("agent_id", models.types.StringUUID(), nullable=True), + sa.Column("current_snapshot_id", models.types.StringUUID(), nullable=True), + sa.Column("node_job_config", models.types.LongText(), nullable=False), + sa.Column("created_by", models.types.StringUUID(), nullable=True), + sa.Column("updated_by", models.types.StringUUID(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("workflow_agent_node_binding_pkey")), + sa.UniqueConstraint( + "tenant_id", + "workflow_id", + "node_id", + name=op.f("workflow_agent_node_binding_node_unique"), + ), + ) + op.create_index( + "workflow_agent_node_binding_agent_idx", + "workflow_agent_node_bindings", + ["tenant_id", "agent_id"], + ) + op.create_index( + "workflow_agent_node_binding_current_snapshot_idx", + "workflow_agent_node_bindings", + ["tenant_id", "current_snapshot_id"], + ) + op.create_index( + "workflow_agent_node_binding_app_idx", + "workflow_agent_node_bindings", + ["tenant_id", "app_id"], + ) + + +def downgrade(): + op.drop_index("workflow_agent_node_binding_app_idx", table_name="workflow_agent_node_bindings") + op.drop_index("workflow_agent_node_binding_current_snapshot_idx", table_name="workflow_agent_node_bindings") + op.drop_index("workflow_agent_node_binding_agent_idx", table_name="workflow_agent_node_bindings") + op.drop_table("workflow_agent_node_bindings") + + op.drop_index("agent_config_snapshot_tenant_created_at_idx", table_name="agent_config_snapshots") + op.drop_index("agent_config_snapshot_tenant_agent_created_at_idx", table_name="agent_config_snapshots") + op.drop_table("agent_config_snapshots") + + op.drop_index("agent_active_config_snapshot_id_idx", table_name="agents") + op.drop_index("agent_tenant_app_id_idx", table_name="agents") + op.drop_index("agent_tenant_workflow_id_idx", table_name="agents") + op.drop_index("agent_tenant_scope_idx", table_name="agents") + op.drop_index("agent_tenant_updated_at_idx", table_name="agents") + op.drop_table("agents") diff --git a/api/migrations/versions/2026_05_19_1000-f8b6b7e9c421_add_agent_config_version_revisions.py b/api/migrations/versions/2026_05_19_1000-f8b6b7e9c421_add_agent_config_version_revisions.py new file mode 100644 index 00000000000000..8378d137443189 --- /dev/null +++ b/api/migrations/versions/2026_05_19_1000-f8b6b7e9c421_add_agent_config_version_revisions.py @@ -0,0 +1,74 @@ +"""add agent config revisions + +Revision ID: f8b6b7e9c421 +Revises: c6a9f4b12d3e +Create Date: 2026-05-19 10:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "f8b6b7e9c421" +down_revision = "c6a9f4b12d3e" +branch_labels = None +depends_on = None + + +def _is_pg(conn) -> bool: + return conn.dialect.name == "postgresql" + + +def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column: + kwargs = {"nullable": nullable, "primary_key": primary_key} + if primary_key and _is_pg(op.get_bind()): + kwargs["server_default"] = sa.text("uuidv7()") + return sa.Column(name, models.types.StringUUID(), **kwargs) + + +def upgrade(): + op.create_table( + "agent_config_revisions", + _uuid_column("id", primary_key=True), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("agent_id", models.types.StringUUID(), nullable=False), + sa.Column("previous_snapshot_id", models.types.StringUUID(), nullable=True), + sa.Column("current_snapshot_id", models.types.StringUUID(), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("operation", sa.String(length=64), nullable=False), + sa.Column("summary", models.types.LongText(), nullable=True), + sa.Column("version_note", models.types.LongText(), nullable=True), + sa.Column("created_by", models.types.StringUUID(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("agent_config_revision_pkey")), + sa.UniqueConstraint( + "agent_id", + "revision", + name=op.f("agent_config_revision_agent_revision_unique"), + ), + ) + op.create_index( + "agent_config_revision_tenant_agent_created_at_idx", + "agent_config_revisions", + ["tenant_id", "agent_id", "created_at"], + ) + op.create_index( + "agent_config_revision_tenant_current_snapshot_created_at_idx", + "agent_config_revisions", + ["tenant_id", "current_snapshot_id", "created_at"], + ) + + +def downgrade(): + op.drop_index( + "agent_config_revision_tenant_current_snapshot_created_at_idx", + table_name="agent_config_revisions", + ) + op.drop_index( + "agent_config_revision_tenant_agent_created_at_idx", + table_name="agent_config_revisions", + ) + op.drop_table("agent_config_revisions") diff --git a/api/models/__init__.py b/api/models/__init__.py index 85be9ca3bd2439..531f725dfc0d8b 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -8,6 +8,19 @@ TenantAccountRole, TenantStatus, ) +from .agent import ( + Agent, + AgentConfigRevision, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentIconType, + AgentKind, + AgentScope, + AgentSource, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint from .comment import ( WorkflowComment, @@ -125,6 +138,15 @@ "AccountIntegrate", "AccountStatus", "AccountTrialAppRecord", + "Agent", + "AgentConfigRevision", + "AgentConfigRevisionOperation", + "AgentConfigSnapshot", + "AgentIconType", + "AgentKind", + "AgentScope", + "AgentSource", + "AgentStatus", "ApiRequest", "ApiToken", "ApiToolProvider", @@ -210,6 +232,8 @@ "UploadFile", "Whitelist", "Workflow", + "WorkflowAgentBindingType", + "WorkflowAgentNodeBinding", "WorkflowAppLog", "WorkflowAppLogCreatedFrom", "WorkflowArchiveLog", diff --git a/api/models/agent.py b/api/models/agent.py new file mode 100644 index 00000000000000..15ad423babe55a --- /dev/null +++ b/api/models/agent.py @@ -0,0 +1,263 @@ +import json +from datetime import datetime +from enum import StrEnum +from typing import Any + +import sqlalchemy as sa +from sqlalchemy import DateTime, Index, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 + +from .agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig +from .base import Base, DefaultFieldsMixin +from .types import EnumText, JSONModelColumn, LongText, StringUUID + + +class AgentKind(StrEnum): + """Agent implementation family. + + This leaves room for future non-Dify agent implementations while keeping + the current roster/workflow APIs scoped to Dify Agent. + """ + + # Native Agent backed by the Dify Agent runtime/protocol. + DIFY_AGENT = "dify_agent" + + +class AgentScope(StrEnum): + """Visibility and lifecycle scope of an Agent record.""" + + # Workspace-visible Agent that can be reused from Agent Roster. + ROSTER = "roster" + # Temporary workflow-local Agent created inside one draft workflow node. + WORKFLOW_ONLY = "workflow_only" + + +class AgentSource(StrEnum): + """Origin that created or imported the Agent.""" + + # Created from an Agent App composer. + AGENT_APP = "agent_app" + # Created from a Workflow Agent Composer flow. + WORKFLOW = "workflow" + # Imported from an external artifact or future CLI/export flow. + IMPORTED = "imported" + # Created by system bootstrap or managed templates. + SYSTEM = "system" + + +class AgentIconType(StrEnum): + """Supported icon storage formats for Agent roster entries.""" + + # ``icon`` stores an uploaded image reference. + IMAGE = "image" + # ``icon`` stores an emoji character. + EMOJI = "emoji" + # ``icon`` stores an external image URL. + LINK = "link" + + +class AgentStatus(StrEnum): + """Soft lifecycle state for Agent records.""" + + # Available for roster lookup, composer use, and workflow binding. + ACTIVE = "active" + # Hidden from active roster queries while preserving historical bindings. + ARCHIVED = "archived" + + +class AgentConfigRevisionOperation(StrEnum): + """Audit operation recorded for Agent Soul version/revision changes.""" + + # Initial version creation for a new Agent. + CREATE_VERSION = "create_version" + # Saves over the user-facing current version by creating a replacement snapshot. + SAVE_CURRENT_VERSION = "save_current_version" + # Creates a new semantic version for the same Agent. + SAVE_NEW_VERSION = "save_new_version" + # Saves composer content into a brand-new roster Agent. + SAVE_NEW_AGENT = "save_new_agent" + # Promotes a workflow-only Agent into the reusable Agent Roster. + SAVE_TO_ROSTER = "save_to_roster" + + +class WorkflowAgentBindingType(StrEnum): + """How a workflow node is bound to an Agent.""" + + # Node uses a reusable Agent from the workspace roster. + ROSTER_AGENT = "roster_agent" + # Node owns a workflow-only Agent that is not visible in the roster. + INLINE_AGENT = "inline_agent" + + +class Agent(DefaultFieldsMixin, Base): + """Workspace-scoped Agent identity used by Agent Roster and workflow-only agents.""" + + __tablename__ = "agents" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="agent_pkey"), + UniqueConstraint("tenant_id", "roster_unique_name"), + Index("agent_tenant_updated_at_idx", "tenant_id", "updated_at"), + Index("agent_tenant_scope_idx", "tenant_id", "scope"), + Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"), + Index("agent_tenant_app_id_idx", "tenant_id", "app_id"), + Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(LongText, nullable=False, default="") + icon_type: Mapped[AgentIconType | None] = mapped_column(EnumText(AgentIconType, length=32), nullable=True) + icon: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + comment="Icon payload interpreted by icon_type: emoji character, image file id, or external URL.", + ) + icon_background: Mapped[str | None] = mapped_column(String(255), nullable=True) + agent_kind: Mapped[AgentKind] = mapped_column( + EnumText(AgentKind, length=32), nullable=False, default=AgentKind.DIFY_AGENT + ) + scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False) + source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False) + app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + status: Mapped[AgentStatus] = mapped_column( + EnumText(AgentStatus, length=32), nullable=False, default=AgentStatus.ACTIVE + ) + roster_unique_name: Mapped[str | None] = mapped_column( + String(255), + sa.Computed("CASE WHEN scope = 'roster' AND status = 'active' THEN name ELSE NULL END"), + nullable=True, + ) + created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + archived_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + +class AgentConfigSnapshot(DefaultFieldsMixin, Base): + """Immutable Agent Soul snapshot. + + ``config_snapshot`` stores ``AgentSoulConfig`` as JSON-backed ``LongText``. + It may contain credential or secret references, but must never contain + plaintext secrets. + """ + + __tablename__ = "agent_config_snapshots" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="agent_config_snapshot_pkey"), + UniqueConstraint("agent_id", "version", name="agent_config_snapshot_agent_version_unique"), + Index("agent_config_snapshot_tenant_agent_created_at_idx", "tenant_id", "agent_id", "created_at"), + Index("agent_config_snapshot_tenant_created_at_idx", "tenant_id", "created_at"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + version: Mapped[int] = mapped_column(sa.Integer, nullable=False) + config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False) + summary: Mapped[str | None] = mapped_column(LongText, nullable=True) + version_note: Mapped[str | None] = mapped_column(LongText, nullable=True) + created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + + @property + def config_snapshot_dict(self) -> dict[str, Any]: + if not self.config_snapshot: + return {} + if hasattr(self.config_snapshot, "model_dump"): + return self.config_snapshot.model_dump(mode="json") + if isinstance(self.config_snapshot, str): + return json.loads(self.config_snapshot) + return dict(self.config_snapshot) + + +class AgentConfigRevision(Base): + """Audit edge for every Agent Soul save operation. + + Revisions link immutable Agent Soul snapshots instead of duplicating the + serialized configuration JSON. + """ + + __tablename__ = "agent_config_revisions" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="agent_config_revision_pkey"), + UniqueConstraint( + "agent_id", + "revision", + name="agent_config_revision_agent_revision_unique", + ), + Index("agent_config_revision_tenant_agent_created_at_idx", "tenant_id", "agent_id", "created_at"), + Index( + "agent_config_revision_tenant_current_snapshot_created_at_idx", + "tenant_id", + "current_snapshot_id", + "created_at", + ), + ) + + id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuidv7())) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + previous_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + current_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + revision: Mapped[int] = mapped_column(sa.Integer, nullable=False) + operation: Mapped[AgentConfigRevisionOperation] = mapped_column( + EnumText(AgentConfigRevisionOperation, length=64), nullable=False + ) + summary: Mapped[str | None] = mapped_column(LongText, nullable=True) + version_note: Mapped[str | None] = mapped_column(LongText, nullable=True) + created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=naive_utc_now, + server_default=func.current_timestamp(), + ) + + +class WorkflowAgentNodeBinding(DefaultFieldsMixin, Base): + """Binding between one workflow node and one Agent config snapshot. + + ``node_job_config`` stores Workflow Node Job JSON only. Agent Soul belongs + to ``AgentConfigSnapshot.config_snapshot`` and must not be duplicated here. + """ + + __tablename__ = "workflow_agent_node_bindings" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="workflow_agent_node_binding_pkey"), + UniqueConstraint( + "tenant_id", + "workflow_id", + "node_id", + name="workflow_agent_node_binding_node_unique", + ), + Index("workflow_agent_node_binding_agent_idx", "tenant_id", "agent_id"), + Index("workflow_agent_node_binding_current_snapshot_idx", "tenant_id", "current_snapshot_id"), + Index("workflow_agent_node_binding_app_idx", "tenant_id", "app_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + node_id: Mapped[str] = mapped_column(String(255), nullable=False) + binding_type: Mapped[WorkflowAgentBindingType] = mapped_column( + EnumText(WorkflowAgentBindingType, length=32), nullable=False + ) + agent_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + current_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + node_job_config: Mapped[Any] = mapped_column(JSONModelColumn(WorkflowNodeJobConfig), nullable=False) + created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + + @property + def node_job_config_dict(self) -> dict[str, Any]: + if not self.node_job_config: + return {} + if hasattr(self.node_job_config, "model_dump"): + return self.node_job_config.model_dump(mode="json") + if isinstance(self.node_job_config, str): + return json.loads(self.node_job_config) + return dict(self.node_job_config) diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py new file mode 100644 index 00000000000000..2044d48e40d068 --- /dev/null +++ b/api/models/agent_config_entities.py @@ -0,0 +1,136 @@ +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class AgentKnowledgeQueryMode(StrEnum): + USER_QUERY = "user_query" + GENERATED_QUERY = "generated_query" + + +class WorkflowNodeJobMode(StrEnum): + LET_AGENT_FIGURE_IT_OUT = "let_agent_figure_it_out" + TELL_AGENT_WHAT_TO_DO = "tell_agent_what_to_do" + + +class DeclaredOutputType(StrEnum): + STRING = "string" + NUMBER = "number" + OBJECT = "object" + ARRAY = "array" + BOOLEAN = "boolean" + FILE = "file" + + +class AgentSoulPromptConfig(BaseModel): + system_prompt: str = "" + + +class AgentSoulSkillsFilesConfig(BaseModel): + files: list[dict[str, Any]] = Field(default_factory=list) + skills: list[dict[str, Any]] = Field(default_factory=list) + + +class AgentSoulToolsConfig(BaseModel): + dify_tools: list[dict[str, Any]] = Field(default_factory=list) + cli_tools: list[dict[str, Any]] = Field(default_factory=list) + + +class AgentSoulKnowledgeConfig(BaseModel): + datasets: list[dict[str, Any]] = Field(default_factory=list) + query_mode: AgentKnowledgeQueryMode | None = None + query_config: dict[str, Any] = Field(default_factory=dict) + + +class AgentSoulHumanConfig(BaseModel): + contacts: list[dict[str, Any]] = Field(default_factory=list) + tools: list[dict[str, Any]] = Field(default_factory=list) + + +class AgentSoulEnvConfig(BaseModel): + variables: list[dict[str, Any]] = Field(default_factory=list) + secret_refs: list[dict[str, Any]] = Field(default_factory=list) + + +class AgentSoulSandboxConfig(BaseModel): + provider: str | None = None + config: dict[str, Any] = Field(default_factory=dict) + + +class AgentSoulMemoryConfig(BaseModel): + scope: str | None = None + budget: str | None = None + artifacts: list[dict[str, Any]] = Field(default_factory=list) + + +class AppVariableConfig(BaseModel): + name: str = Field(min_length=1, max_length=255) + type: str = Field(min_length=1, max_length=64) + required: bool = False + default: Any = None + + +class AgentSoulConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: int = 1 + prompt: AgentSoulPromptConfig = Field(default_factory=AgentSoulPromptConfig) + skills_files: AgentSoulSkillsFilesConfig = Field(default_factory=AgentSoulSkillsFilesConfig) + tools: AgentSoulToolsConfig = Field(default_factory=AgentSoulToolsConfig) + knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig) + human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig) + env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig) + sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig) + memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig) + app_features: dict[str, Any] = Field(default_factory=dict) + app_variables: list[AppVariableConfig] = Field(default_factory=list) + misc_legacy: dict[str, Any] = Field(default_factory=dict) + + +class DeclaredOutputFileConfig(BaseModel): + extensions: list[str] = Field(default_factory=list) + mime_types: list[str] = Field(default_factory=list) + + +class DeclaredOutputCheckConfig(BaseModel): + type: str = Field(min_length=1, max_length=64) + prompt: str | None = None + benchmark_file_ref: dict[str, Any] | None = None + + +class DeclaredOutputFailureStrategy(BaseModel): + on_type_check_failed: str | None = None + on_output_check_failed: str | None = None + max_retries: int = Field(default=0, ge=0, le=10) + + +class DeclaredOutputConfig(BaseModel): + id: str | None = None + name: str = Field(min_length=1, max_length=255) + type: DeclaredOutputType + description: str | None = None + required: bool = True + file: DeclaredOutputFileConfig | None = None + checks: list[DeclaredOutputCheckConfig] = Field(default_factory=list) + failure_strategy: DeclaredOutputFailureStrategy | None = None + + @model_validator(mode="after") + def validate_file_metadata(self) -> "DeclaredOutputConfig": + if self.type == DeclaredOutputType.FILE and self.file is None: + self.file = DeclaredOutputFileConfig() + if self.type != DeclaredOutputType.FILE and self.file is not None: + raise ValueError("file metadata is only allowed for file outputs") + return self + + +class WorkflowNodeJobConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: int = 1 + mode: WorkflowNodeJobMode = WorkflowNodeJobMode.TELL_AGENT_WHAT_TO_DO + workflow_prompt: str = "" + previous_node_output_refs: list[dict[str, Any]] = Field(default_factory=list) + declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list) + human_contacts: list[dict[str, Any]] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/api/models/types.py b/api/models/types.py index 4f35c31a27ccd8..23028220f6d9c5 100644 --- a/api/models/types.py +++ b/api/models/types.py @@ -1,8 +1,10 @@ import enum +import json import uuid from typing import Any, cast import sqlalchemy as sa +from pydantic import BaseModel from sqlalchemy import CHAR, TEXT, VARCHAR, LargeBinary, TypeDecorator from sqlalchemy.dialects.mysql import LONGBLOB, LONGTEXT from sqlalchemy.dialects.postgresql import BYTEA, JSONB, UUID @@ -61,6 +63,45 @@ def process_result_value(self, value: str | None, dialect: Dialect) -> str | Non return value +class JSONModelColumn[T: BaseModel](TypeDecorator[T | None]): + """Store a Pydantic model as dialect-adjusted LongText JSON.""" + + impl = TEXT + cache_ok = True + + _model_class: type[T] + + def __init__(self, model_class: type[T]): + if not issubclass(model_class, BaseModel): + raise TypeError(f"{model_class.__module__}.{model_class.__name__} must be a Pydantic BaseModel subclass") + self._model_class = model_class + super().__init__() + + def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]: + if dialect.name == "postgresql": + return dialect.type_descriptor(TEXT()) + elif dialect.name == "mysql": + return dialect.type_descriptor(LONGTEXT()) + else: + return dialect.type_descriptor(TEXT()) + + def process_bind_param(self, value: T | dict[str, Any] | str | None, dialect: Dialect) -> str | None: + if value is None: + return None + if isinstance(value, self._model_class): + model = value + elif isinstance(value, str): + model = self._model_class.model_validate_json(value) + else: + model = self._model_class.model_validate(value) + return json.dumps(model.model_dump(mode="json"), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + def process_result_value(self, value: str | None, dialect: Dialect) -> T | None: + if value is None or value == "": + return None + return self._model_class.model_validate_json(value) + + class BinaryData(TypeDecorator[bytes | None]): impl = LargeBinary cache_ok = True diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 2c8f0bd169fa8f..3e036313f18c9d 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -340,6 +340,110 @@ Check if activation token is valid | ---- | ----------- | ------ | | 200 | Success | [ActivationCheckResponse](#activationcheckresponse) | +### /agents + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [RosterAgentCreatePayload](#rosteragentcreatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /agents/invite-options + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /agents/{agent_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | +| payload | body | | Yes | [RosterAgentUpdatePayload](#rosteragentupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /agents/{agent_id}/versions + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /agents/{agent_id}/versions/{version_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | +| version_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + ### /all-workspaces #### GET @@ -863,6 +967,66 @@ Run draft workflow for advanced chat application | 400 | Invalid request parameters | | 403 | Permission denied | +### /apps/{app_id}/agent-composer + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PUT +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/agent-composer/candidates + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/agent-composer/validate + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + ### /apps/{app_id}/agent/logs #### GET @@ -3048,6 +3212,103 @@ Run draft workflow loop node | 403 | Permission denied | | 404 | Node not found | +### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PUT +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | +| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | +| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | +| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + ### /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run #### GET @@ -10207,6 +10468,35 @@ Get banner list | model_mode | string | Model mode | Yes | | model_name | string | Model name | Yes | +#### AgentIconType + +Supported icon storage formats for Agent roster entries. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AgentIconType | string | Supported icon storage formats for Agent roster entries. | | + +#### AgentIdPath + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_id | string | | Yes | + +#### AgentInviteOptionsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | Workflow app id for in-current-workflow markers | No | +| keyword | string | | No | +| limit | integer | | No | +| page | integer | | No | + +#### AgentKnowledgeQueryMode + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AgentKnowledgeQueryMode | string | | | + #### AgentLogQuery | Name | Type | Description | Required | @@ -10214,6 +10504,80 @@ Get banner list | conversation_id | string | Conversation UUID | Yes | | message_id | string | Message UUID | Yes | +#### AgentSoulConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_features | object | | No | +| app_variables | [ [AppVariableConfig](#appvariableconfig) ] | | No | +| env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No | +| human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No | +| knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No | +| memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No | +| misc_legacy | object | | No | +| prompt | [AgentSoulPromptConfig](#agentsoulpromptconfig) | | No | +| sandbox | [AgentSoulSandboxConfig](#agentsoulsandboxconfig) | | No | +| schema_version | integer | | No | +| skills_files | [AgentSoulSkillsFilesConfig](#agentsoulskillsfilesconfig) | | No | +| tools | [AgentSoulToolsConfig](#agentsoultoolsconfig) | | No | + +#### AgentSoulEnvConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| secret_refs | [ object ] | | No | +| variables | [ object ] | | No | + +#### AgentSoulHumanConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contacts | [ object ] | | No | +| tools | [ object ] | | No | + +#### AgentSoulKnowledgeConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasets | [ object ] | | No | +| query_config | object | | No | +| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No | + +#### AgentSoulMemoryConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| artifacts | [ object ] | | No | +| budget | string | | No | +| scope | string | | No | + +#### AgentSoulPromptConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| system_prompt | string | | No | + +#### AgentSoulSandboxConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | object | | No | +| provider | string | | No | + +#### AgentSoulSkillsFilesConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | | No | +| skills | [ object ] | | No | + +#### AgentSoulToolsConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| cli_tools | [ object ] | | No | +| dify_tools | [ object ] | | No | + #### AgentThought | Name | Type | Description | Required | @@ -10662,6 +11026,15 @@ AppMCPServer Status Enum | enabled | boolean | Enable or disable tracing | Yes | | tracing_provider | string | Tracing provider | No | +#### AppVariableConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | | | No | +| name | string | | Yes | +| required | boolean | | No | +| type | string | | Yes | + #### AudioTranscriptResponse | Name | Type | Description | Required | @@ -10910,6 +11283,48 @@ Button styles for user actions. | ---- | ---- | ----------- | -------- | | doc_name | string | Compliance document name | Yes | +#### ComposerBindingPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_id | string | | No | +| binding_type | string | *Enum:* `"inline_agent"`, `"roster_agent"` | Yes | +| current_snapshot_id | string | | No | + +#### ComposerSavePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | No | +| binding | [ComposerBindingPayload](#composerbindingpayload) | | No | +| client_revision_id | string | | No | +| idempotency_key | string | | No | +| new_agent_name | string | | No | +| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | No | +| save_strategy | [ComposerSaveStrategy](#composersavestrategy) | | Yes | +| soul_lock | [ComposerSoulLockPayload](#composersoullockpayload) | | No | +| variant | [ComposerVariant](#composervariant) | | Yes | +| version_note | string | | No | + +#### ComposerSaveStrategy + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ComposerSaveStrategy | string | | | + +#### ComposerSoulLockPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| locked | boolean | | No | +| unlocked_from_version_id | string | | No | + +#### ComposerVariant + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ComposerVariant | string | | | + #### Condition Condition detail @@ -11445,6 +11860,48 @@ Condition detail | ---- | ---- | ----------- | -------- | | DebugPermission | string | | | +#### DeclaredOutputCheckConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| benchmark_file_ref | object | | No | +| prompt | string | | No | +| type | string | | Yes | + +#### DeclaredOutputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| checks | [ [DeclaredOutputCheckConfig](#declaredoutputcheckconfig) ] | | No | +| description | string | | No | +| failure_strategy | [DeclaredOutputFailureStrategy](#declaredoutputfailurestrategy) | | No | +| file | [DeclaredOutputFileConfig](#declaredoutputfileconfig) | | No | +| id | string | | No | +| name | string | | Yes | +| required | boolean | | No | +| type | [DeclaredOutputType](#declaredoutputtype) | | Yes | + +#### DeclaredOutputFailureStrategy + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| max_retries | integer | | No | +| on_output_check_failed | string | | No | +| on_type_check_failed | string | | No | + +#### DeclaredOutputFileConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| extensions | [ string ] | | No | +| mime_types | [ string ] | | No | + +#### DeclaredOutputType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DeclaredOutputType | string | | | + #### DefaultBlockConfigQuery | Name | Type | Description | Required | @@ -13420,6 +13877,36 @@ Form input definition. | top_k | integer | | Yes | | weights | [WeightModel](#weightmodel) | | No | +#### RosterAgentCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | [AgentIconType](#agenticontype) | | No | +| name | string | | Yes | +| version_note | string | | No | + +#### RosterAgentUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | [AgentIconType](#agenticontype) | | No | +| name | string | | No | + +#### RosterListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword | string | | No | +| limit | integer | | No | +| page | integer | | No | + #### Rule | Name | Type | Description | Required | @@ -14570,6 +15057,24 @@ in form definiton, or a variable while the workflow is running. | page | integer | | No | | user_id | string | | No | +#### WorkflowNodeJobConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No | +| human_contacts | [ object ] | | No | +| metadata | object | | No | +| mode | [WorkflowNodeJobMode](#workflownodejobmode) | | No | +| previous_node_output_refs | [ object ] | | No | +| schema_version | integer | | No | +| workflow_prompt | string | | No | + +#### WorkflowNodeJobMode + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| WorkflowNodeJobMode | string | | | + #### WorkflowOnlineUser | Name | Type | Description | Required | diff --git a/api/pyproject.toml b/api/pyproject.toml index 95fc38e2c8575c..ec389f18017550 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "boto3>=1.43.6", "celery>=5.6.3", "croniter>=6.2.2", + "dify-agent", "flask>=3.1.3,<4.0.0", "flask-cors>=6.0.2", "gevent>=26.4.0", @@ -114,7 +115,6 @@ override-dependencies = [ ############################################################ dev = [ "coverage>=7.13.4", - "dify-agent", "dotenv-linter>=0.7.0", "faker>=40.15.0", "lxml-stubs>=0.5.1", diff --git a/api/services/agent/__init__.py b/api/services/agent/__init__.py new file mode 100644 index 00000000000000..c3a5ebaec4a1ae --- /dev/null +++ b/api/services/agent/__init__.py @@ -0,0 +1,4 @@ +from .composer_service import AgentComposerService +from .roster_service import AgentRosterService + +__all__ = ["AgentComposerService", "AgentRosterService"] diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py new file mode 100644 index 00000000000000..c1b396cb82b043 --- /dev/null +++ b/api/services/agent/composer_service.py @@ -0,0 +1,767 @@ +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError + +from extensions.ext_database import db +from models.agent import ( + Agent, + AgentConfigRevision, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentKind, + AgentScope, + AgentSource, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) +from models.workflow import Workflow +from services.agent.composer_validator import ComposerConfigValidator +from services.agent.errors import AgentNameConflictError, AgentNotFoundError, AgentVersionNotFoundError +from services.entities.agent_entities import ( + AgentSoulConfig, + ComposerCandidatesResponse, + ComposerSavePayload, + ComposerSaveStrategy, + ComposerVariant, + WorkflowNodeJobConfig, +) + + +class AgentComposerService: + @classmethod + def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]: + workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) + binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) + if not binding: + return cls._empty_workflow_state(app_id=app_id, workflow_id=workflow.id, node_id=node_id) + + 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) + + @classmethod + def save_workflow_composer( + cls, *, tenant_id: str, app_id: str, node_id: str, account_id: str, payload: ComposerSavePayload + ) -> dict[str, Any]: + if payload.variant != ComposerVariant.WORKFLOW: + raise ValueError("Workflow composer endpoint only accepts workflow variant") + + ComposerConfigValidator.validate_save_payload(payload) + workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) + binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) + + match payload.save_strategy: + case ComposerSaveStrategy.NODE_JOB_ONLY: + binding = cls._save_node_job_only( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow.id, + node_id=node_id, + account_id=account_id, + binding=binding, + payload=payload, + ) + case ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION: + binding = cls._save_to_current_version( + tenant_id=tenant_id, account_id=account_id, binding=binding, payload=payload + ) + case ComposerSaveStrategy.SAVE_AS_NEW_VERSION: + binding = cls._save_as_new_version( + tenant_id=tenant_id, account_id=account_id, binding=binding, payload=payload + ) + case ComposerSaveStrategy.SAVE_AS_NEW_AGENT: + binding = cls._save_as_new_agent( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow.id, + node_id=node_id, + account_id=account_id, + binding=binding, + payload=payload, + ) + case ComposerSaveStrategy.SAVE_TO_ROSTER: + binding = cls._save_to_roster( + tenant_id=tenant_id, account_id=account_id, binding=binding, payload=payload + ) + + db.session.commit() + 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) + + @classmethod + def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]: + agent = db.session.scalar( + select(Agent) + .where( + Agent.tenant_id == tenant_id, + Agent.app_id == app_id, + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + ) + .order_by(Agent.created_at.desc()) + .limit(1) + ) + if not agent: + raise AgentNotFoundError() + version = cls._require_version( + tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id + ) + return { + "variant": ComposerVariant.AGENT_APP.value, + "agent": cls._serialize_agent(agent), + "active_config_snapshot": cls._serialize_version(version), + "agent_soul": version.config_snapshot_dict, + "save_options": [ + ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, + ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value, + ], + } + + @classmethod + def save_agent_app_composer( + cls, *, tenant_id: str, app_id: str, account_id: str, payload: ComposerSavePayload + ) -> dict[str, Any]: + if payload.variant != ComposerVariant.AGENT_APP: + raise ValueError("Agent App composer endpoint only accepts agent_app variant") + ComposerConfigValidator.validate_save_payload(payload) + if payload.agent_soul is None: + raise ValueError("agent_soul is required") + + agent = db.session.scalar( + select(Agent) + .where( + Agent.tenant_id == tenant_id, + Agent.app_id == app_id, + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + ) + .order_by(Agent.created_at.desc()) + .limit(1) + ) + if not agent: + agent = Agent( + tenant_id=tenant_id, + name=payload.new_agent_name or "Untitled Agent", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + app_id=app_id, + status=AgentStatus.ACTIVE, + created_by=account_id, + updated_by=account_id, + ) + db.session.add(agent) + try: + db.session.flush() + except IntegrityError as exc: + db.session.rollback() + raise AgentNameConflictError() from exc + + if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id: + version = cls._create_config_version( + tenant_id=tenant_id, + agent_id=agent.id, + account_id=account_id, + agent_soul=payload.agent_soul, + operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION, + version_note=payload.version_note, + ) + agent.active_config_snapshot_id = version.id + else: + current_snapshot = cls._require_version( + tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id + ) + version = cls._update_current_version( + current_snapshot=current_snapshot, + account_id=account_id, + agent_soul=payload.agent_soul, + operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, + version_note=payload.version_note, + ) + agent.active_config_snapshot_id = version.id + agent.updated_by = account_id + + db.session.commit() + return cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id) + + @classmethod + def get_workflow_candidates(cls, *, app_id: str) -> dict[str, Any]: + response = ComposerCandidatesResponse( + variant=ComposerVariant.WORKFLOW, + allowed_node_job_candidates={ + "previous_node_outputs": [], + "declare_output_types": ["string", "number", "object", "array", "boolean", "file"], + "human_contacts": [], + }, + allowed_soul_candidates={ + "skills_files": [], + "dify_tools": [], + "cli_tools": [], + "knowledge_datasets": [], + "human_contacts": [], + }, + ) + return response.model_dump(mode="json") + + @classmethod + def get_agent_app_candidates(cls, *, app_id: str) -> dict[str, Any]: + response = ComposerCandidatesResponse( + variant=ComposerVariant.AGENT_APP, + allowed_node_job_candidates={}, + allowed_soul_candidates={ + "skills_files": [], + "dify_tools": [], + "cli_tools": [], + "knowledge_datasets": [], + "human_contacts": [], + }, + ) + return response.model_dump(mode="json") + + @classmethod + def calculate_impact(cls, *, tenant_id: str, current_snapshot_id: str) -> dict[str, Any]: + bindings = list( + db.session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == tenant_id, + WorkflowAgentNodeBinding.current_snapshot_id == current_snapshot_id, + ) + ).all() + ) + return { + "current_snapshot_id": current_snapshot_id, + "workflow_node_count": len(bindings), + "bindings": [ + { + "app_id": binding.app_id, + "workflow_id": binding.workflow_id, + "node_id": binding.node_id, + } + for binding in bindings + ], + } + + @classmethod + def _save_node_job_only( + cls, + *, + tenant_id: str, + app_id: str, + workflow_id: str, + node_id: str, + account_id: str, + binding: WorkflowAgentNodeBinding | None, + payload: ComposerSavePayload, + ) -> WorkflowAgentNodeBinding: + node_job = payload.node_job or WorkflowNodeJobConfig() + if binding: + binding.node_job_config = node_job + binding.updated_by = account_id + return binding + + agent_soul = payload.agent_soul or AgentSoulConfig() + 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, + ) + binding = WorkflowAgentNodeBinding( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + node_id=node_id, + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id=agent.id, + current_snapshot_id=agent.active_config_snapshot_id, + node_job_config=node_job, + created_by=account_id, + updated_by=account_id, + ) + db.session.add(binding) + db.session.flush() + return binding + + @classmethod + def _save_to_current_version( + cls, + *, + tenant_id: str, + account_id: str, + binding: WorkflowAgentNodeBinding | None, + payload: ComposerSavePayload, + ) -> WorkflowAgentNodeBinding: + binding = cls._require_binding(binding) + if payload.agent_soul is None: + raise ValueError("agent_soul is required") + current_snapshot = cls._require_version( + tenant_id=tenant_id, + agent_id=binding.agent_id, + version_id=binding.current_snapshot_id, + ) + version = cls._update_current_version( + current_snapshot=current_snapshot, + account_id=account_id, + agent_soul=payload.agent_soul, + operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, + version_note=payload.version_note, + ) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) + agent.active_config_snapshot_id = version.id + agent.updated_by = account_id + binding.current_snapshot_id = version.id + if payload.node_job is not None: + binding.node_job_config = payload.node_job + binding.updated_by = account_id + return binding + + @classmethod + def _save_as_new_version( + cls, + *, + tenant_id: str, + account_id: str, + binding: WorkflowAgentNodeBinding | None, + payload: ComposerSavePayload, + ) -> WorkflowAgentNodeBinding: + binding = cls._require_binding(binding) + if not binding.agent_id or payload.agent_soul is None: + raise ValueError("agent_id and agent_soul are required") + version = cls._create_config_version( + tenant_id=tenant_id, + agent_id=binding.agent_id, + account_id=account_id, + agent_soul=payload.agent_soul, + operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION, + version_note=payload.version_note, + ) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) + agent.active_config_snapshot_id = version.id + agent.updated_by = account_id + binding.current_snapshot_id = version.id + binding.updated_by = account_id + if payload.node_job is not None: + binding.node_job_config = payload.node_job + return binding + + @classmethod + def _save_as_new_agent( + cls, + *, + tenant_id: str, + app_id: str, + workflow_id: str, + node_id: str, + account_id: str, + binding: WorkflowAgentNodeBinding | None, + payload: ComposerSavePayload, + ) -> WorkflowAgentNodeBinding: + if payload.agent_soul is None: + raise ValueError("agent_soul is required") + agent_name = payload.new_agent_name or "Untitled Agent" + agent = cls._create_roster_agent_for_composer( + tenant_id=tenant_id, + account_id=account_id, + name=agent_name, + agent_soul=payload.agent_soul, + operation=AgentConfigRevisionOperation.SAVE_NEW_AGENT, + version_note=payload.version_note, + ) + node_job = payload.node_job or WorkflowNodeJobConfig() + if not binding: + binding = WorkflowAgentNodeBinding( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + node_id=node_id, + created_by=account_id, + ) + db.session.add(binding) + binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT + binding.agent_id = agent.id + binding.current_snapshot_id = agent.active_config_snapshot_id + binding.node_job_config = node_job + binding.updated_by = account_id + db.session.flush() + return binding + + @classmethod + def _save_to_roster( + cls, + *, + tenant_id: str, + account_id: str, + binding: WorkflowAgentNodeBinding | None, + payload: ComposerSavePayload, + ) -> WorkflowAgentNodeBinding: + binding = cls._require_binding(binding) + source_agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) + source_version = cls._require_version( + tenant_id=tenant_id, + agent_id=source_agent.id, + version_id=binding.current_snapshot_id, + ) + agent_soul = payload.agent_soul or AgentSoulConfig.model_validate(source_version.config_snapshot_dict) + agent_name = payload.new_agent_name or source_agent.name + roster_agent = cls._create_roster_agent_for_composer( + tenant_id=tenant_id, + account_id=account_id, + name=agent_name, + agent_soul=agent_soul, + operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER, + version_note=payload.version_note, + ) + binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT + binding.agent_id = roster_agent.id + binding.current_snapshot_id = roster_agent.active_config_snapshot_id + binding.updated_by = account_id + if payload.node_job is not None: + binding.node_job_config = payload.node_job + return binding + + @classmethod + def _create_workflow_only_agent( + cls, + *, + tenant_id: str, + app_id: str, + workflow_id: str, + node_id: str, + account_id: str, + agent_soul: AgentSoulConfig, + ) -> Agent: + agent = Agent( + tenant_id=tenant_id, + name=f"Workflow Agent {node_id}", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + app_id=app_id, + workflow_id=workflow_id, + workflow_node_id=node_id, + status=AgentStatus.ACTIVE, + created_by=account_id, + updated_by=account_id, + ) + db.session.add(agent) + db.session.flush() + version = cls._create_config_version( + tenant_id=tenant_id, + agent_id=agent.id, + account_id=account_id, + agent_soul=agent_soul, + operation=AgentConfigRevisionOperation.CREATE_VERSION, + version_note=None, + ) + agent.active_config_snapshot_id = version.id + return agent + + @classmethod + def _create_roster_agent_for_composer( + cls, + *, + tenant_id: str, + account_id: str, + name: str, + agent_soul: AgentSoulConfig, + operation: AgentConfigRevisionOperation, + version_note: str | None, + ) -> Agent: + agent = Agent( + tenant_id=tenant_id, + name=name, + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + created_by=account_id, + updated_by=account_id, + ) + db.session.add(agent) + try: + db.session.flush() + except IntegrityError as exc: + db.session.rollback() + raise AgentNameConflictError() from exc + version = cls._create_config_version( + tenant_id=tenant_id, + agent_id=agent.id, + account_id=account_id, + agent_soul=agent_soul, + operation=operation, + version_note=version_note, + ) + agent.active_config_snapshot_id = version.id + return agent + + @classmethod + def _create_config_version( + cls, + *, + tenant_id: str, + agent_id: str, + account_id: str, + agent_soul: AgentSoulConfig, + operation: AgentConfigRevisionOperation, + version_note: str | None, + previous_snapshot_id: str | None = None, + ) -> AgentConfigSnapshot: + next_version = ( + db.session.scalar( + select(func.max(AgentConfigSnapshot.version)).where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + ) + ) + or 0 + ) + 1 + version = AgentConfigSnapshot( + tenant_id=tenant_id, + agent_id=agent_id, + version=next_version, + config_snapshot=agent_soul, + version_note=version_note, + created_by=account_id, + ) + db.session.add(version) + db.session.flush() + revision = AgentConfigRevision( + tenant_id=tenant_id, + agent_id=agent_id, + previous_snapshot_id=previous_snapshot_id, + current_snapshot_id=version.id, + revision=cls._next_revision(tenant_id=tenant_id, agent_id=agent_id), + operation=operation, + version_note=version_note, + created_by=account_id, + ) + db.session.add(revision) + db.session.flush() + return version + + @classmethod + def _update_current_version( + cls, + *, + current_snapshot: AgentConfigSnapshot, + account_id: str, + agent_soul: AgentSoulConfig, + operation: AgentConfigRevisionOperation, + version_note: str | None, + ) -> AgentConfigSnapshot: + return cls._create_config_version( + tenant_id=current_snapshot.tenant_id, + agent_id=current_snapshot.agent_id, + account_id=account_id, + agent_soul=agent_soul, + operation=operation, + version_note=version_note, + previous_snapshot_id=current_snapshot.id, + ) + + @classmethod + def _next_revision(cls, *, tenant_id: str, agent_id: str) -> int: + return ( + db.session.scalar( + select(func.max(AgentConfigRevision.revision)).where( + AgentConfigRevision.tenant_id == tenant_id, + AgentConfigRevision.agent_id == agent_id, + ) + ) + or 0 + ) + 1 + + @classmethod + def _get_draft_workflow(cls, *, tenant_id: str, app_id: str) -> Workflow: + workflow = db.session.scalar( + select(Workflow) + .where( + Workflow.tenant_id == tenant_id, + Workflow.app_id == app_id, + Workflow.version == Workflow.VERSION_DRAFT, + ) + .limit(1) + ) + if not workflow: + raise ValueError("Draft workflow not found") + return workflow + + @classmethod + def _get_workflow_binding( + cls, *, tenant_id: str, workflow_id: str, node_id: str + ) -> WorkflowAgentNodeBinding | None: + return db.session.scalar( + select(WorkflowAgentNodeBinding) + .where( + WorkflowAgentNodeBinding.tenant_id == tenant_id, + WorkflowAgentNodeBinding.workflow_id == workflow_id, + WorkflowAgentNodeBinding.node_id == node_id, + ) + .limit(1) + ) + + @classmethod + def _require_binding(cls, binding: WorkflowAgentNodeBinding | None) -> WorkflowAgentNodeBinding: + if not binding: + raise ValueError("Workflow agent binding not found") + return binding + + @classmethod + def _require_agent(cls, *, tenant_id: str, agent_id: str | None) -> Agent: + if not agent_id: + raise AgentNotFoundError() + agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1)) + if not agent: + raise AgentNotFoundError() + return agent + + @classmethod + def _get_agent_if_present(cls, *, tenant_id: str, agent_id: str | None) -> Agent | None: + if not agent_id: + return None + return db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1)) + + @classmethod + def _require_version(cls, *, tenant_id: str, agent_id: str | None, version_id: str | None) -> AgentConfigSnapshot: + if not agent_id or not version_id: + raise AgentVersionNotFoundError() + version = db.session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == version_id, + ) + .limit(1) + ) + if not version: + raise AgentVersionNotFoundError() + return version + + @classmethod + def _get_version_if_present( + cls, *, tenant_id: str, agent_id: str | None, version_id: str | None + ) -> AgentConfigSnapshot | None: + if not agent_id or not version_id: + return None + return db.session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == version_id, + ) + .limit(1) + ) + + @classmethod + def _empty_workflow_state(cls, *, app_id: str, workflow_id: str, node_id: str) -> dict[str, Any]: + return { + "variant": ComposerVariant.WORKFLOW.value, + "agent": None, + "active_config_snapshot": None, + "binding": None, + "soul_lock": {"locked": False, "can_unlock": False, "reason": "workflow_only_empty"}, + "agent_soul": AgentSoulConfig().model_dump(mode="json"), + "node_job": WorkflowNodeJobConfig().model_dump(mode="json"), + "save_options": [ComposerSaveStrategy.NODE_JOB_ONLY.value, ComposerSaveStrategy.SAVE_TO_ROSTER.value], + "impact_summary": None, + "app_id": app_id, + "workflow_id": workflow_id, + "node_id": node_id, + } + + @classmethod + def _serialize_workflow_state( + cls, + *, + binding: WorkflowAgentNodeBinding, + agent: Agent | None, + version: AgentConfigSnapshot | None, + ) -> dict[str, Any]: + locked = bool(agent and agent.scope == AgentScope.ROSTER) + save_options = [ComposerSaveStrategy.NODE_JOB_ONLY.value] + if locked: + save_options.extend( + [ + ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, + ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value, + ComposerSaveStrategy.SAVE_AS_NEW_AGENT.value, + ] + ) + else: + save_options.append(ComposerSaveStrategy.SAVE_TO_ROSTER.value) + return { + "variant": ComposerVariant.WORKFLOW.value, + "agent": cls._serialize_agent(agent) if agent else None, + "active_config_snapshot": cls._serialize_version(version), + "binding": { + "id": binding.id, + "binding_type": binding.binding_type.value, + "agent_id": binding.agent_id, + "current_snapshot_id": binding.current_snapshot_id, + "workflow_id": binding.workflow_id, + "node_id": binding.node_id, + }, + "soul_lock": { + "locked": locked, + "can_unlock": locked, + "reason": "roster_agent_shared_version" if locked else "workflow_only_agent", + }, + "agent_soul": cls._workflow_agent_soul_config(version.config_snapshot_dict) + if version + else AgentSoulConfig().model_dump(mode="json"), + "node_job": binding.node_job_config_dict, + "save_options": save_options, + "impact_summary": cls.calculate_impact( + tenant_id=binding.tenant_id, current_snapshot_id=binding.current_snapshot_id + ) + if binding.current_snapshot_id + else None, + } + + @classmethod + def _serialize_agent(cls, agent: Agent) -> dict[str, Any]: + return { + "id": agent.id, + "name": agent.name, + "description": agent.description, + "scope": agent.scope.value, + "status": agent.status.value, + "active_config_snapshot_id": agent.active_config_snapshot_id, + } + + @classmethod + def _serialize_version(cls, version: AgentConfigSnapshot | None) -> dict[str, Any] | None: + if not version: + return None + return { + "id": version.id, + "version": version.version, + "version_note": version.version_note, + "created_by": version.created_by, + "created_at": version.created_at.isoformat() if version.created_at else None, + } + + @staticmethod + def _workflow_agent_soul_config(config_snapshot: dict[str, Any]) -> dict[str, Any]: + agent_soul = dict(config_snapshot) + agent_soul["app_features"] = {} + agent_soul["app_variables"] = [] + return agent_soul diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py new file mode 100644 index 00000000000000..9c91496f687230 --- /dev/null +++ b/api/services/agent/composer_validator.py @@ -0,0 +1,71 @@ +from typing import Any + +from pydantic import ValidationError + +from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError +from services.entities.agent_entities import ( + AgentSoulConfig, + ComposerSavePayload, + ComposerVariant, + WorkflowNodeJobConfig, +) + +_PLAINTEXT_SECRET_KEYS = { + "api_key", + "apikey", + "authorization", + "password", + "secret", + "secret_key", +} + + +class ComposerConfigValidator: + @classmethod + def validate_save_payload(cls, payload: ComposerSavePayload) -> None: + if payload.variant == ComposerVariant.WORKFLOW and payload.soul_lock.locked and payload.agent_soul is not None: + raise AgentSoulLockedError() + + if payload.agent_soul is not None: + cls.validate_agent_soul(payload.agent_soul) + if payload.node_job is not None: + cls.validate_node_job(payload.node_job) + + @classmethod + def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None: + cls._reject_plaintext_secrets(agent_soul.model_dump(mode="json"), path="agent_soul") + + @classmethod + def validate_node_job(cls, node_job: WorkflowNodeJobConfig) -> None: + cls._reject_plaintext_secrets(node_job.model_dump(mode="json"), path="node_job") + + @classmethod + def validate_agent_soul_dict(cls, value: dict[str, Any]) -> AgentSoulConfig: + try: + config = AgentSoulConfig.model_validate(value) + except ValidationError as exc: + raise InvalidComposerConfigError(str(exc)) from exc + cls.validate_agent_soul(config) + return config + + @classmethod + def validate_node_job_dict(cls, value: dict[str, Any]) -> WorkflowNodeJobConfig: + try: + config = WorkflowNodeJobConfig.model_validate(value) + except ValidationError as exc: + raise InvalidComposerConfigError(str(exc)) from exc + cls.validate_node_job(config) + return config + + @classmethod + def _reject_plaintext_secrets(cls, value: Any, *, path: str) -> None: + if isinstance(value, dict): + for key, nested in value.items(): + normalized_key = key.lower().replace("-", "_") + nested_path = f"{path}.{key}" + if normalized_key in _PLAINTEXT_SECRET_KEYS and isinstance(nested, str) and nested: + raise PlaintextSecretNotAllowedError(f"Plaintext secret is not allowed at {nested_path}") + cls._reject_plaintext_secrets(nested, path=nested_path) + elif isinstance(value, list): + for index, nested in enumerate(value): + cls._reject_plaintext_secrets(nested, path=f"{path}[{index}]") diff --git a/api/services/agent/errors.py b/api/services/agent/errors.py new file mode 100644 index 00000000000000..dcc8f69961ff15 --- /dev/null +++ b/api/services/agent/errors.py @@ -0,0 +1,29 @@ +from werkzeug.exceptions import BadRequest, Conflict, NotFound + + +class AgentNotFoundError(NotFound): + description = "Agent not found." + + +class AgentVersionNotFoundError(NotFound): + description = "Agent config version not found." + + +class AgentNameConflictError(Conflict): + description = "Agent name already exists." + + +class AgentArchivedError(Conflict): + description = "Archived agent cannot be modified." + + +class AgentSoulLockedError(BadRequest): + description = "Agent Soul is locked for this workflow node." + + +class InvalidComposerConfigError(BadRequest): + description = "Invalid agent composer config." + + +class PlaintextSecretNotAllowedError(BadRequest): + description = "Plaintext secret values are not allowed in Agent config." diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py new file mode 100644 index 00000000000000..67300679bf4e50 --- /dev/null +++ b/api/services/agent/roster_service.py @@ -0,0 +1,320 @@ +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError + +from libs.datetime_utils import naive_utc_now +from models.agent import ( + Agent, + AgentConfigRevision, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentKind, + AgentScope, + AgentSource, + AgentStatus, + WorkflowAgentNodeBinding, +) +from models.workflow import Workflow +from services.agent.composer_validator import ComposerConfigValidator +from services.agent.errors import ( + AgentArchivedError, + AgentNameConflictError, + AgentNotFoundError, + AgentVersionNotFoundError, +) +from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload + + +class AgentRosterService: + def __init__(self, session: Any): + self._session = session + + @staticmethod + def serialize_agent(agent: Agent, active_version: AgentConfigSnapshot | None = None) -> dict[str, Any]: + return { + "id": agent.id, + "name": agent.name, + "description": agent.description, + "icon_type": agent.icon_type.value if agent.icon_type else None, + "icon": agent.icon, + "icon_background": agent.icon_background, + "agent_kind": agent.agent_kind.value, + "scope": agent.scope.value, + "source": agent.source.value, + "app_id": agent.app_id, + "workflow_id": agent.workflow_id, + "workflow_node_id": agent.workflow_node_id, + "active_config_snapshot_id": agent.active_config_snapshot_id, + "active_config_snapshot": AgentRosterService.serialize_version(active_version) if active_version else None, + "status": agent.status.value, + "created_by": agent.created_by, + "updated_by": agent.updated_by, + "archived_by": agent.archived_by, + "archived_at": agent.archived_at.isoformat() if agent.archived_at else None, + "created_at": agent.created_at.isoformat() if agent.created_at else None, + "updated_at": agent.updated_at.isoformat() if agent.updated_at else None, + } + + @staticmethod + def serialize_version(version: AgentConfigSnapshot | None) -> dict[str, Any] | None: + if version is None: + return None + return { + "id": version.id, + "agent_id": version.agent_id, + "version": version.version, + "summary": version.summary, + "version_note": version.version_note, + "created_by": version.created_by, + "created_at": version.created_at.isoformat() if version.created_at else None, + } + + def list_roster_agents( + self, *, tenant_id: str, page: int = 1, limit: int = 20, keyword: str | None = None + ) -> dict[str, Any]: + stmt = select(Agent).where( + Agent.tenant_id == tenant_id, + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + ) + if keyword: + from libs.helper import escape_like_pattern + + escaped_keyword = escape_like_pattern(keyword) + stmt = stmt.where(Agent.name.ilike(f"%{escaped_keyword}%", escape="\\")) + stmt = stmt.order_by(Agent.updated_at.desc()) + + total = self._session.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + agents = list(self._session.scalars(stmt.offset((page - 1) * limit).limit(limit)).all()) + versions_by_id = self._load_versions_by_id( + [agent.active_config_snapshot_id for agent in agents if agent.active_config_snapshot_id] + ) + + data = [] + for agent in agents: + active_version = ( + versions_by_id.get(agent.active_config_snapshot_id) if agent.active_config_snapshot_id else None + ) + data.append(self.serialize_agent(agent, active_version)) + + return { + "data": data, + "page": page, + "limit": limit, + "total": total, + "has_more": page * limit < total, + } + + def list_invite_options( + self, *, tenant_id: str, page: int = 1, limit: int = 20, keyword: str | None = None, app_id: str | None = None + ) -> dict[str, Any]: + result = self.list_roster_agents(tenant_id=tenant_id, page=page, limit=limit, keyword=keyword) + usage_by_agent_id: dict[str, list[str]] = {} + if app_id: + draft_workflow = self._session.scalar( + select(Workflow) + .where( + Workflow.tenant_id == tenant_id, + Workflow.app_id == app_id, + Workflow.version == Workflow.VERSION_DRAFT, + ) + .limit(1) + ) + if draft_workflow: + agent_ids = [item["id"] for item in result["data"]] + if agent_ids: + bindings = self._session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == tenant_id, + WorkflowAgentNodeBinding.workflow_id == draft_workflow.id, + WorkflowAgentNodeBinding.agent_id.in_(agent_ids), + ) + ).all() + for binding in bindings: + if binding.agent_id: + usage_by_agent_id.setdefault(binding.agent_id, []).append(binding.node_id) + + for item in result["data"]: + existing_node_ids = usage_by_agent_id.get(item["id"], []) + item["is_in_current_workflow"] = bool(existing_node_ids) + item["in_current_workflow_count"] = len(existing_node_ids) + item["existing_node_ids"] = existing_node_ids + return result + + def create_roster_agent( + self, + *, + tenant_id: str, + account_id: str, + payload: RosterAgentCreatePayload, + source: AgentSource = AgentSource.AGENT_APP, + ) -> Agent: + ComposerConfigValidator.validate_agent_soul(payload.agent_soul) + + agent = Agent( + tenant_id=tenant_id, + name=payload.name, + description=payload.description, + icon_type=payload.icon_type, + icon=payload.icon, + icon_background=payload.icon_background, + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=source, + status=AgentStatus.ACTIVE, + created_by=account_id, + updated_by=account_id, + ) + self._session.add(agent) + try: + self._session.flush() + except IntegrityError as exc: + self._session.rollback() + raise AgentNameConflictError() from exc + + version = AgentConfigSnapshot( + tenant_id=tenant_id, + agent_id=agent.id, + version=1, + config_snapshot=payload.agent_soul, + version_note=payload.version_note, + created_by=account_id, + ) + self._session.add(version) + self._session.flush() + + revision = AgentConfigRevision( + tenant_id=tenant_id, + agent_id=agent.id, + current_snapshot_id=version.id, + revision=1, + operation=AgentConfigRevisionOperation.CREATE_VERSION, + version_note=payload.version_note, + created_by=account_id, + ) + self._session.add(revision) + agent.active_config_snapshot_id = version.id + + try: + self._session.commit() + except IntegrityError as exc: + self._session.rollback() + raise AgentNameConflictError() from exc + return agent + + def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]: + agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + active_version = self._get_version( + tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id + ) + return self.serialize_agent(agent, active_version) + + def update_roster_agent( + self, *, tenant_id: str, agent_id: str, account_id: str, payload: RosterAgentUpdatePayload + ) -> dict[str, Any]: + agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + if agent.status == AgentStatus.ARCHIVED: + raise AgentArchivedError() + + update_data = payload.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(agent, key, value) + agent.updated_by = account_id + + try: + self._session.commit() + except IntegrityError as exc: + self._session.rollback() + raise AgentNameConflictError() from exc + return self.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent_id) + + def archive_roster_agent(self, *, tenant_id: str, agent_id: str, account_id: str) -> None: + agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + if agent.status == AgentStatus.ARCHIVED: + return + agent.status = AgentStatus.ARCHIVED + agent.archived_by = account_id + agent.archived_at = naive_utc_now() + agent.updated_by = account_id + self._session.commit() + + def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]: + self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + versions = list( + self._session.scalars( + select(AgentConfigSnapshot) + .where(AgentConfigSnapshot.tenant_id == tenant_id, AgentConfigSnapshot.agent_id == agent_id) + .order_by(AgentConfigSnapshot.version.desc()) + ).all() + ) + return [ + serialized_version + for version in versions + if (serialized_version := self.serialize_version(version)) is not None + ] + + def get_agent_version_detail(self, *, tenant_id: str, agent_id: str, version_id: str) -> dict[str, Any]: + self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id) + revisions = list( + self._session.scalars( + select(AgentConfigRevision) + .where( + AgentConfigRevision.tenant_id == tenant_id, + AgentConfigRevision.agent_id == agent_id, + AgentConfigRevision.current_snapshot_id == version_id, + ) + .order_by(AgentConfigRevision.revision.desc()) + ).all() + ) + result = self.serialize_version(version) or {} + result["config_snapshot"] = version.config_snapshot_dict + result["revisions"] = [ + { + "id": revision.id, + "previous_snapshot_id": revision.previous_snapshot_id, + "current_snapshot_id": revision.current_snapshot_id, + "revision": revision.revision, + "operation": revision.operation.value, + "summary": revision.summary, + "version_note": revision.version_note, + "created_by": revision.created_by, + "created_at": revision.created_at.isoformat() if revision.created_at else None, + } + for revision in revisions + ] + return result + + def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent: + stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id) + if roster_only: + stmt = stmt.where(Agent.scope == AgentScope.ROSTER) + agent = self._session.scalar(stmt.limit(1)) + if not agent: + raise AgentNotFoundError() + return agent + + def _get_version(self, *, tenant_id: str, agent_id: str, version_id: str | None) -> AgentConfigSnapshot: + if not version_id: + raise AgentVersionNotFoundError() + version = self._session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == version_id, + ) + .limit(1) + ) + if not version: + raise AgentVersionNotFoundError() + return version + + def _load_versions_by_id(self, version_ids: list[str]) -> dict[str, AgentConfigSnapshot]: + if not version_ids: + return {} + versions = self._session.scalars( + select(AgentConfigSnapshot).where(AgentConfigSnapshot.id.in_(version_ids)) + ).all() + return {version.id: version for version in versions} diff --git a/api/services/entities/agent_entities.py b/api/services/entities/agent_entities.py new file mode 100644 index 00000000000000..1b06c70dff570f --- /dev/null +++ b/api/services/entities/agent_entities.py @@ -0,0 +1,93 @@ +from enum import StrEnum +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + +from models.agent import AgentIconType +from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig + + +class ComposerVariant(StrEnum): + WORKFLOW = "workflow" + AGENT_APP = "agent_app" + + +class ComposerSaveStrategy(StrEnum): + NODE_JOB_ONLY = "node_job_only" + SAVE_TO_CURRENT_VERSION = "save_to_current_version" + SAVE_AS_NEW_VERSION = "save_as_new_version" + SAVE_AS_NEW_AGENT = "save_as_new_agent" + SAVE_TO_ROSTER = "save_to_roster" + + +class ComposerBindingPayload(BaseModel): + binding_type: Literal["roster_agent", "inline_agent"] + agent_id: str | None = None + current_snapshot_id: str | None = None + + +class ComposerSoulLockPayload(BaseModel): + locked: bool = True + unlocked_from_version_id: str | None = None + + +class ComposerSavePayload(BaseModel): + variant: ComposerVariant + binding: ComposerBindingPayload | None = None + soul_lock: ComposerSoulLockPayload = Field(default_factory=ComposerSoulLockPayload) + agent_soul: AgentSoulConfig | None = None + node_job: WorkflowNodeJobConfig | None = None + save_strategy: ComposerSaveStrategy + version_note: str | None = None + 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) + + @model_validator(mode="after") + def validate_variant_sections(self) -> "ComposerSavePayload": + if self.variant == ComposerVariant.AGENT_APP and self.node_job is not None: + raise ValueError("Agent App Variant must not include workflow node job config") + if self.variant == ComposerVariant.AGENT_APP and self.agent_soul is not None: + if self.agent_soul.app_variables and self.save_strategy == ComposerSaveStrategy.NODE_JOB_ONLY: + raise ValueError("Agent App Variant cannot use node_job_only save strategy") + if self.variant == ComposerVariant.WORKFLOW and self.agent_soul is not None: + if self.agent_soul.app_variables: + raise ValueError("Workflow Variant must not include app variables") + if self.agent_soul.app_features: + raise ValueError("Workflow Variant must not include app features") + return self + + +class RosterAgentCreatePayload(BaseModel): + name: str = Field(min_length=1, max_length=255) + description: str = "" + icon_type: AgentIconType | None = None + icon: str | None = Field(default=None, max_length=255) + icon_background: str | None = Field(default=None, max_length=255) + agent_soul: AgentSoulConfig = Field(default_factory=AgentSoulConfig) + version_note: str | None = None + + +class RosterAgentUpdatePayload(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + icon_type: AgentIconType | None = None + icon: str | None = Field(default=None, max_length=255) + icon_background: str | None = Field(default=None, max_length=255) + + +class RosterListQuery(BaseModel): + page: int = Field(default=1, ge=1) + limit: int = Field(default=20, ge=1, le=100) + keyword: str | None = None + + +class ComposerCandidateCapabilities(BaseModel): + human_roster_available: bool = False + + +class ComposerCandidatesResponse(BaseModel): + variant: ComposerVariant + allowed_node_job_candidates: dict[str, Any] = Field(default_factory=dict) + allowed_soul_candidates: dict[str, Any] = Field(default_factory=dict) + capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities) diff --git a/api/tests/unit_tests/clients/__init__.py b/api/tests/unit_tests/clients/__init__.py new file mode 100644 index 00000000000000..a50b3410ab6fbd --- /dev/null +++ b/api/tests/unit_tests/clients/__init__.py @@ -0,0 +1 @@ +"""Client unit tests.""" diff --git a/api/tests/unit_tests/clients/agent_backend/__init__.py b/api/tests/unit_tests/clients/agent_backend/__init__.py new file mode 100644 index 00000000000000..4142d8527cbd02 --- /dev/null +++ b/api/tests/unit_tests/clients/agent_backend/__init__.py @@ -0,0 +1 @@ +"""Agent backend client contract tests.""" diff --git a/api/tests/unit_tests/clients/agent_backend/test_client.py b/api/tests/unit_tests/clients/agent_backend/test_client.py new file mode 100644 index 00000000000000..7e3be4255122ac --- /dev/null +++ b/api/tests/unit_tests/clients/agent_backend/test_client.py @@ -0,0 +1,126 @@ +from collections.abc import Iterator + +import pytest +from dify_agent.client import DifyAgentHTTPError, DifyAgentStreamError, DifyAgentTimeoutError, DifyAgentValidationError +from dify_agent.protocol import ( + CancelRunRequest, + CancelRunResponse, + CreateRunRequest, + CreateRunResponse, + ExecutionContext, + RunEvent, + RunStartedEvent, + RunStatusResponse, +) + +from clients.agent_backend import ( + AgentBackendHTTPError, + AgentBackendModelConfig, + AgentBackendRunRequestBuilder, + AgentBackendStreamError, + AgentBackendTransportError, + AgentBackendValidationError, + AgentBackendWorkflowNodeRunInput, + DifyAgentBackendRunClient, +) + + +def _request(): + return AgentBackendRunRequestBuilder().build_for_workflow_node( + AgentBackendWorkflowNodeRunInput( + model=AgentBackendModelConfig( + tenant_id="tenant-1", + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + execution_context=ExecutionContext(tenant_id="tenant-1", invoke_from="workflow_run"), + workflow_node_job_prompt="Do the task.", + user_prompt="hello", + ) + ) + + +class _SuccessfulClient: + def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse: + assert isinstance(request, CreateRunRequest) + return CreateRunResponse(run_id="run-1", status="running") + + def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse: + del request + return CancelRunResponse(run_id=run_id, status="cancelled") + + def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + del after + yield RunStartedEvent(id="1-0", run_id=run_id) + + def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse: + del timeout_seconds + return RunStatusResponse.model_validate( + { + "run_id": run_id, + "status": "succeeded", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + } + ) + + +def test_dify_agent_backend_run_client_delegates_sync_methods(): + client = DifyAgentBackendRunClient(_SuccessfulClient()) + + created = client.create_run(_request()) + cancelled = client.cancel_run(created.run_id) + events = list(client.stream_events(created.run_id)) + status = client.wait_run(created.run_id) + + assert created.run_id == "run-1" + assert cancelled.status == "cancelled" + assert events[0].type == "run_started" + assert status.status == "succeeded" + + +def test_dify_agent_backend_run_client_maps_validation_error(): + class InvalidClient(_SuccessfulClient): + def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse: + raise DifyAgentValidationError(detail={"field": "bad"}) + + with pytest.raises(AgentBackendValidationError) as exc_info: + DifyAgentBackendRunClient(InvalidClient()).create_run(_request()) + + assert exc_info.value.detail == {"field": "bad"} + + +def test_dify_agent_backend_run_client_maps_http_error(): + class HTTPErrorClient(_SuccessfulClient): + def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse: + raise DifyAgentHTTPError(status_code=503, detail="unavailable") + + with pytest.raises(AgentBackendHTTPError) as exc_info: + DifyAgentBackendRunClient(HTTPErrorClient()).create_run(_request()) + + assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "unavailable" + + +def test_dify_agent_backend_run_client_maps_timeout_error(): + class TimeoutClient(_SuccessfulClient): + def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse: + raise DifyAgentTimeoutError("timeout") + + with pytest.raises(AgentBackendTransportError) as exc_info: + DifyAgentBackendRunClient(TimeoutClient()).wait_run("run-1") + + assert str(exc_info.value) == "timeout" + + +def test_dify_agent_backend_run_client_maps_stream_error(): + class StreamClient(_SuccessfulClient): + def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + raise DifyAgentStreamError("bad stream") + yield + + with pytest.raises(AgentBackendStreamError) as exc_info: + list(DifyAgentBackendRunClient(StreamClient()).stream_events("run-1")) + + assert str(exc_info.value) == "bad stream" diff --git a/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py b/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py new file mode 100644 index 00000000000000..79f7d14d31ee0c --- /dev/null +++ b/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py @@ -0,0 +1,132 @@ +from agenton.compositor import CompositorSessionSnapshot +from dify_agent.protocol import ( + PydanticAIStreamRunEvent, + RunCancelledEvent, + RunCancelledEventData, + RunFailedEvent, + RunFailedEventData, + RunPausedEvent, + RunPausedEventData, + RunStartedEvent, + RunSucceededEvent, + RunSucceededEventData, +) +from pydantic_ai.messages import FinalResultEvent + +from clients.agent_backend import ( + AgentBackendInternalEventType, + AgentBackendRunCancelledInternalEvent, + AgentBackendRunEventAdapter, + AgentBackendRunFailedInternalEvent, + AgentBackendRunPausedInternalEvent, + AgentBackendRunStartedInternalEvent, + AgentBackendRunSucceededInternalEvent, + AgentBackendStreamInternalEvent, +) + + +def test_event_adapter_maps_run_started(): + adapted = AgentBackendRunEventAdapter().adapt(RunStartedEvent(id="1-0", run_id="run-1")) + + assert adapted == [ + AgentBackendRunStartedInternalEvent( + run_id="run-1", + source_event_id="1-0", + ) + ] + + +def test_event_adapter_maps_pydantic_ai_stream_event(): + adapted = AgentBackendRunEventAdapter().adapt( + PydanticAIStreamRunEvent( + id="2-0", + run_id="run-1", + data=FinalResultEvent(tool_name=None, tool_call_id=None), + ) + ) + + assert len(adapted) == 1 + event = adapted[0] + assert isinstance(event, AgentBackendStreamInternalEvent) + assert event.type == AgentBackendInternalEventType.STREAM_EVENT + assert event.event_kind == "final_result" + assert event.data["event_kind"] == "final_result" + + +def test_event_adapter_maps_run_succeeded_to_final_output(): + snapshot = CompositorSessionSnapshot(layers=[]) + adapted = AgentBackendRunEventAdapter().adapt( + RunSucceededEvent( + id="3-0", + run_id="run-1", + data=RunSucceededEventData(output={"summary": "done"}, session_snapshot=snapshot), + ) + ) + + assert adapted == [ + AgentBackendRunSucceededInternalEvent( + run_id="run-1", + source_event_id="3-0", + output={"summary": "done"}, + session_snapshot=snapshot, + ) + ] + + +def test_event_adapter_maps_run_failed_to_failed_result(): + adapted = AgentBackendRunEventAdapter().adapt( + RunFailedEvent( + id="4-0", + run_id="run-1", + data=RunFailedEventData(error="boom", reason="runtime"), + ) + ) + + assert adapted == [ + AgentBackendRunFailedInternalEvent( + run_id="run-1", + source_event_id="4-0", + error="boom", + reason="runtime", + ) + ] + + +def test_event_adapter_maps_run_paused_to_resumable_pause(): + snapshot = CompositorSessionSnapshot(layers=[]) + adapted = AgentBackendRunEventAdapter().adapt( + RunPausedEvent( + id="5-0", + run_id="run-1", + data=RunPausedEventData(reason="human_handoff", message="Need review", session_snapshot=snapshot), + ) + ) + + assert adapted == [ + AgentBackendRunPausedInternalEvent( + run_id="run-1", + source_event_id="5-0", + reason="human_handoff", + message="Need review", + session_snapshot=snapshot, + ) + ] + + +def test_event_adapter_maps_run_cancelled_to_terminal_cancelled(): + adapted = AgentBackendRunEventAdapter().adapt( + RunCancelledEvent( + id="6-0", + run_id="run-1", + data=RunCancelledEventData(reason="user_cancelled", message="Stopped by user"), + ) + ) + + assert adapted == [ + AgentBackendRunCancelledInternalEvent( + run_id="run-1", + source_event_id="6-0", + reason="user_cancelled", + message="Stopped by user", + ) + ] diff --git a/api/tests/unit_tests/clients/agent_backend/test_fake_client.py b/api/tests/unit_tests/clients/agent_backend/test_fake_client.py new file mode 100644 index 00000000000000..80b398988aecc8 --- /dev/null +++ b/api/tests/unit_tests/clients/agent_backend/test_fake_client.py @@ -0,0 +1,66 @@ +from dify_agent.protocol import ExecutionContext + +from clients.agent_backend import ( + AgentBackendModelConfig, + AgentBackendRunRequestBuilder, + AgentBackendWorkflowNodeRunInput, + FakeAgentBackendRunClient, + FakeAgentBackendScenario, +) + + +def _request(): + return AgentBackendRunRequestBuilder().build_for_workflow_node( + AgentBackendWorkflowNodeRunInput( + model=AgentBackendModelConfig( + tenant_id="tenant-1", + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + execution_context=ExecutionContext(tenant_id="tenant-1", invoke_from="workflow_run"), + workflow_node_job_prompt="Do the task.", + user_prompt="hello", + ) + ) + + +def test_fake_client_stream_is_deterministic(): + client = FakeAgentBackendRunClient() + request = _request() + + created = client.create_run(request) + first = [event.model_dump(mode="json") for event in client.stream_events(created.run_id)] + second = [event.model_dump(mode="json") for event in client.stream_events(created.run_id)] + + assert created.run_id == "fake-run-1" + assert client.request is request + assert first == second + assert [event["type"] for event in first] == ["run_started", "run_succeeded"] + assert first[-1]["data"]["output"] == {"text": "hello agent"} + + +def test_fake_client_stream_honors_cursor(): + events = list(FakeAgentBackendRunClient().stream_events("fake-run-1", after="1-0")) + + assert len(events) == 1 + assert events[0].type == "run_succeeded" + + +def test_fake_client_failed_scenario_returns_failed_status_and_event(): + client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED) + + status = client.wait_run("fake-run-1") + events = list(client.stream_events("fake-run-1")) + + assert status.status == "failed" + assert status.error == "fake failure" + assert events[-1].type == "run_failed" + assert events[-1].data.error == "fake failure" + + +def test_fake_client_cancel_run_returns_cancelled_status(): + cancelled = FakeAgentBackendRunClient().cancel_run("fake-run-1") + + assert cancelled.run_id == "fake-run-1" + assert cancelled.status == "cancelled" diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py new file mode 100644 index 00000000000000..44c795d70d96b5 --- /dev/null +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -0,0 +1,132 @@ +import pytest +from agenton.layers import ExitIntent +from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID +from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID +from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID +from dify_agent.protocol import ( + DIFY_AGENT_MODEL_LAYER_ID, + DIFY_AGENT_OUTPUT_LAYER_ID, + CreateRunRequest, + ExecutionContext, +) +from pydantic import ValidationError + +from clients.agent_backend import ( + AGENT_SOUL_PROMPT_LAYER_ID, + WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, + WORKFLOW_USER_PROMPT_LAYER_ID, + AgentBackendModelConfig, + AgentBackendOutputConfig, + AgentBackendRunRequestBuilder, + AgentBackendWorkflowNodeRunInput, + redact_for_agent_backend_log, +) + + +def _run_input() -> AgentBackendWorkflowNodeRunInput: + return AgentBackendWorkflowNodeRunInput( + model=AgentBackendModelConfig( + tenant_id="tenant-1", + plugin_id="langgenius/openai", + user_id="user-1", + model_provider="openai", + model="gpt-test", + credentials={"api_key": "secret-key"}, + ), + execution_context=ExecutionContext( + tenant_id="tenant-1", + workflow_id="workflow-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + node_execution_id="node-execution-1", + invoke_from="workflow_run", + ), + idempotency_key="workflow-run-1:node-execution-1", + agent_soul_prompt="You are a careful reviewer.", + workflow_node_job_prompt="Review the previous node output.", + user_prompt="Summarize the report.", + output=AgentBackendOutputConfig( + json_schema={ + "type": "object", + "properties": {"summary": {"type": "string"}}, + "required": ["summary"], + } + ), + metadata={"workflow_id": "workflow-1", "node_id": "node-1"}, + ) + + +def test_request_builder_outputs_dify_agent_create_run_request(): + request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input()) + + assert isinstance(request, CreateRunRequest) + assert [layer.name for layer in request.composition.layers] == [ + AGENT_SOUL_PROMPT_LAYER_ID, + WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, + WORKFLOW_USER_PROMPT_LAYER_ID, + "plugin", + DIFY_AGENT_MODEL_LAYER_ID, + DIFY_AGENT_OUTPUT_LAYER_ID, + ] + assert request.on_exit.default is ExitIntent.DELETE + assert request.execution_context is not None + assert request.execution_context.node_execution_id == "node-execution-1" + assert request.idempotency_key == "workflow-run-1:node-execution-1" + assert request.metadata == {"workflow_id": "workflow-1", "node_id": "node-1"} + + +def test_request_builder_separates_agent_soul_and_workflow_job_prompt(): + request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input()) + layers = {layer.name: layer for layer in request.composition.layers} + + assert layers[AGENT_SOUL_PROMPT_LAYER_ID].type == PLAIN_PROMPT_LAYER_TYPE_ID + assert layers[AGENT_SOUL_PROMPT_LAYER_ID].metadata["origin"] == "agent_soul" + assert layers[WORKFLOW_NODE_JOB_PROMPT_LAYER_ID].metadata["origin"] == "workflow_node_job" + assert layers[WORKFLOW_USER_PROMPT_LAYER_ID].metadata["origin"] == "workflow_user_prompt" + + dumped = request.model_dump(mode="json") + assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are a careful reviewer." + assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Review the previous node output." + assert dumped["composition"]["layers"][2]["config"]["user"] == "Summarize the report." + + +def test_request_builder_sets_model_and_output_layer_contract_ids(): + request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input()) + layers = {layer.name: layer for layer in request.composition.layers} + + assert layers["plugin"].type == DIFY_PLUGIN_LAYER_TYPE_ID + assert layers[DIFY_AGENT_MODEL_LAYER_ID].type == DIFY_PLUGIN_LLM_LAYER_TYPE_ID + assert layers[DIFY_AGENT_MODEL_LAYER_ID].deps == {"plugin": "plugin"} + assert layers[DIFY_AGENT_OUTPUT_LAYER_ID].type == DIFY_OUTPUT_LAYER_TYPE_ID + + +def test_request_builder_can_suspend_on_exit_for_resume_or_babysit_paths(): + run_input = _run_input() + run_input.suspend_on_exit = True + + request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input) + + assert request.on_exit.default is ExitIntent.SUSPEND + + +def test_request_builder_rejects_blank_prompts(): + with pytest.raises(ValidationError): + AgentBackendWorkflowNodeRunInput( + model=AgentBackendModelConfig( + tenant_id="tenant-1", + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + execution_context=ExecutionContext(tenant_id="tenant-1", invoke_from="workflow_run"), + workflow_node_job_prompt=" ", + user_prompt="hello", + ) + + +def test_redact_for_agent_backend_log_hides_credentials(): + request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input()) + + redacted = redact_for_agent_backend_log(request) + + assert redacted["composition"]["layers"][4]["config"]["credentials"] == "[REDACTED]" diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py new file mode 100644 index 00000000000000..0e38bfc1035bea --- /dev/null +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -0,0 +1,239 @@ +from types import SimpleNamespace + +import pytest + +from controllers.console.agent import composer as composer_controller +from controllers.console.agent import roster as roster_controller +from controllers.console.agent.composer import ( + AgentAppComposerApi, + AgentAppComposerCandidatesApi, + AgentAppComposerValidateApi, + WorkflowAgentComposerApi, + WorkflowAgentComposerCandidatesApi, + WorkflowAgentComposerImpactApi, + WorkflowAgentComposerSaveToRosterApi, + WorkflowAgentComposerValidateApi, +) +from controllers.console.agent.roster import ( + AgentInviteOptionsApi, + AgentRosterDetailApi, + AgentRosterListApi, + AgentRosterVersionDetailApi, + AgentRosterVersionsApi, +) +from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant + + +def _unwrap(method): + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + return method + + +@pytest.fixture +def account(): + return SimpleNamespace(id="account-1") + + +@pytest.fixture(autouse=True) +def patch_account_context(monkeypatch, account): + monkeypatch.setattr(roster_controller, "current_account_with_tenant", lambda: (account, "tenant-1")) + monkeypatch.setattr(composer_controller, "current_account_with_tenant", lambda: (account, "tenant-1")) + + +def test_roster_list_get_parses_query_and_calls_service(app, monkeypatch): + captured = {} + + def list_roster_agents(_self, **kwargs): + captured.update(kwargs) + return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False} + + monkeypatch.setattr(roster_controller.AgentRosterService, "list_roster_agents", list_roster_agents) + + with app.test_request_context("/console/api/agents?page=2&limit=5&keyword=analyst"): + result = _unwrap(AgentRosterListApi.get)(AgentRosterListApi()) + + assert result["page"] == 2 + assert captured == {"tenant_id": "tenant-1", "page": 2, "limit": 5, "keyword": "analyst"} + + +def test_roster_list_post_creates_agent_and_returns_detail(app, monkeypatch): + created_agent = SimpleNamespace(id="agent-1") + monkeypatch.setattr( + roster_controller.AgentRosterService, + "create_roster_agent", + lambda _self, **kwargs: created_agent, + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_roster_agent_detail", + lambda _self, **kwargs: {"id": kwargs["agent_id"], "tenant_id": kwargs["tenant_id"]}, + ) + + with app.test_request_context(json={"name": "Analyst", "agent_soul": {"prompt": {"system_prompt": "x"}}}): + result, status = _unwrap(AgentRosterListApi.post)(AgentRosterListApi()) + + assert status == 201 + assert result == {"id": "agent-1", "tenant_id": "tenant-1"} + + +def test_invite_options_get_parses_app_id(app, monkeypatch): + captured = {} + + def list_invite_options(_self, **kwargs): + captured.update(kwargs) + return {"data": []} + + monkeypatch.setattr(roster_controller.AgentRosterService, "list_invite_options", list_invite_options) + + with app.test_request_context("/console/api/agents/invite-options?page=1&limit=10&app_id=app-1"): + result = _unwrap(AgentInviteOptionsApi.get)(AgentInviteOptionsApi()) + + assert result == {"data": []} + assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"} + + +def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch): + agent_id = "00000000-0000-0000-0000-000000000001" + version_id = "00000000-0000-0000-0000-000000000002" + archived = {} + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_roster_agent_detail", + lambda _self, **kwargs: {"id": kwargs["agent_id"]}, + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "update_roster_agent", + lambda _self, **kwargs: {"id": kwargs["agent_id"], "description": kwargs["payload"].description}, + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "archive_roster_agent", + lambda _self, **kwargs: archived.update(kwargs), + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "list_agent_versions", + lambda _self, **kwargs: [{"id": "version-1"}], + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_agent_version_detail", + lambda _self, **kwargs: {"id": kwargs["version_id"], "agent_id": kwargs["agent_id"]}, + ) + + assert _unwrap(AgentRosterDetailApi.get)(AgentRosterDetailApi(), agent_id)["id"] == agent_id + with app.test_request_context(json={"description": "updated"}): + assert _unwrap(AgentRosterDetailApi.patch)(AgentRosterDetailApi(), agent_id)["description"] == "updated" + assert _unwrap(AgentRosterDetailApi.delete)(AgentRosterDetailApi(), agent_id) == ("", 204) + assert archived["account_id"] == "account-1" + assert _unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), agent_id) == {"data": [{"id": "version-1"}]} + assert _unwrap(AgentRosterVersionDetailApi.get)(AgentRosterVersionDetailApi(), agent_id, version_id) == { + "id": version_id, + "agent_id": agent_id, + } + + +def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monkeypatch): + app_model = SimpleNamespace(id="app-1") + payload = { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "binding": {"binding_type": "roster_agent", "current_snapshot_id": "version-1"}, + } + monkeypatch.setattr( + composer_controller.AgentComposerService, + "load_workflow_composer", + lambda **kwargs: {"node_id": kwargs["node_id"]}, + ) + monkeypatch.setattr( + composer_controller.AgentComposerService, + "save_workflow_composer", + lambda **kwargs: {"saved": kwargs["payload"].save_strategy.value, "account_id": kwargs["account_id"]}, + ) + monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr( + composer_controller.AgentComposerService, + "get_workflow_candidates", + lambda **kwargs: {"data": []}, + ) + monkeypatch.setattr( + composer_controller.AgentComposerService, + "calculate_impact", + lambda **kwargs: {"current_snapshot_id": kwargs["current_snapshot_id"], "workflow_node_count": 1}, + ) + + assert _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1") == { + "node_id": "node-1" + } + with app.test_request_context(json=payload): + assert _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1") == { + "saved": "node_job_only", + "account_id": "account-1", + } + assert _unwrap(WorkflowAgentComposerValidateApi.post)( + WorkflowAgentComposerValidateApi(), app_model, "node-1" + ) == {"result": "success", "errors": []} + assert _unwrap(WorkflowAgentComposerCandidatesApi.get)( + WorkflowAgentComposerCandidatesApi(), app_model, "node-1" + ) == {"data": []} + with app.test_request_context(json=payload): + assert _unwrap(WorkflowAgentComposerImpactApi.post)(WorkflowAgentComposerImpactApi(), app_model, "node-1") == { + "current_snapshot_id": "version-1", + "workflow_node_count": 1, + } + assert ( + _unwrap(WorkflowAgentComposerSaveToRosterApi.post)( + WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1" + )["saved"] + == "node_job_only" + ) + + +def test_workflow_impact_returns_empty_without_version(app): + payload = {"variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value} + + with app.test_request_context(json=payload): + result = _unwrap(WorkflowAgentComposerImpactApi.post)( + WorkflowAgentComposerImpactApi(), SimpleNamespace(id="app-1"), "node-1" + ) + + assert result == {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []} + + +def test_agent_app_composer_get_put_validate_and_candidates(app, monkeypatch): + app_model = SimpleNamespace(id="app-1") + payload = { + "variant": ComposerVariant.AGENT_APP.value, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, + "agent_soul": {"prompt": {"system_prompt": "x"}}, + } + monkeypatch.setattr( + composer_controller.AgentComposerService, + "load_agent_app_composer", + lambda **kwargs: {"loaded": True}, + ) + monkeypatch.setattr( + composer_controller.AgentComposerService, + "save_agent_app_composer", + lambda **kwargs: {"saved": kwargs["payload"].variant.value, "account_id": kwargs["account_id"]}, + ) + monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr( + composer_controller.AgentComposerService, + "get_agent_app_candidates", + lambda **kwargs: {"data": []}, + ) + + assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model) == {"loaded": True} + with app.test_request_context(json=payload): + assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model) == { + "saved": "agent_app", + "account_id": "account-1", + } + assert _unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == { + "result": "success", + "errors": [], + } + assert _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model) == {"data": []} diff --git a/api/tests/unit_tests/models/test_agent.py b/api/tests/unit_tests/models/test_agent.py new file mode 100644 index 00000000000000..2efc64ac8669d0 --- /dev/null +++ b/api/tests/unit_tests/models/test_agent.py @@ -0,0 +1,192 @@ +import json +from typing import cast + +import pytest +import sqlalchemy as sa +from sqlalchemy.exc import IntegrityError + +from models.agent import ( + Agent, + AgentConfigRevision, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentIconType, + AgentKind, + AgentScope, + AgentSource, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) +from models.agent_config_entities import AgentSoulConfig +from models.types import JSONModelColumn, LongText + + +def test_agent_enums_match_prd_boundaries(): + assert AgentKind.DIFY_AGENT.value == "dify_agent" + assert AgentIconType.EMOJI.value == "emoji" + assert AgentScope.ROSTER.value == "roster" + assert AgentScope.WORKFLOW_ONLY.value == "workflow_only" + assert AgentSource.AGENT_APP.value == "agent_app" + assert AgentSource.WORKFLOW.value == "workflow" + assert AgentStatus.ACTIVE.value == "active" + assert AgentStatus.ARCHIVED.value == "archived" + assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version" + assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent" + assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent" + + +def test_agent_table_uses_db_unique_constraint_for_active_roster_names(): + agent_table = cast(sa.Table, Agent.__table__) + unique_constraints = { + str(constraint.name): tuple(column.name for column in constraint.columns) + for constraint in agent_table.constraints + if isinstance(constraint, sa.UniqueConstraint) + } + + assert unique_constraints["agents_tenant_id_key"] == ("tenant_id", "roster_unique_name") + + roster_unique_name = agent_table.c.roster_unique_name + assert roster_unique_name.computed is not None + computed_sql = str(roster_unique_name.computed.sqltext) + assert "scope = 'roster'" in computed_sql + assert "status = 'active'" in computed_sql + + indexes = {str(index.name): tuple(column.name for column in index.columns) for index in agent_table.indexes} + assert indexes["agent_tenant_updated_at_idx"] == ("tenant_id", "updated_at") + assert indexes["agent_tenant_scope_idx"] == ("tenant_id", "scope") + + +def test_active_roster_agent_name_unique_constraint_allows_archived_and_workflow_only_duplicates(): + engine = sa.create_engine("sqlite:///:memory:") + agent_table = cast(sa.Table, Agent.__table__) + agent_table.create(engine) + insert_agent = agent_table.insert() + + with engine.begin() as conn: + conn.execute( + insert_agent, + { + "id": "agent-1", + "tenant_id": "tenant-1", + "name": "Analyst", + "scope": AgentScope.ROSTER.value, + "source": AgentSource.WORKFLOW.value, + "status": AgentStatus.ACTIVE.value, + }, + ) + conn.execute( + insert_agent, + { + "id": "agent-2", + "tenant_id": "tenant-1", + "name": "Analyst", + "scope": AgentScope.ROSTER.value, + "source": AgentSource.WORKFLOW.value, + "status": AgentStatus.ARCHIVED.value, + }, + ) + conn.execute( + insert_agent, + { + "id": "agent-3", + "tenant_id": "tenant-1", + "name": "Analyst", + "scope": AgentScope.WORKFLOW_ONLY.value, + "source": AgentSource.WORKFLOW.value, + "status": AgentStatus.ACTIVE.value, + }, + ) + + with pytest.raises(IntegrityError): + conn.execute( + insert_agent, + { + "id": "agent-4", + "tenant_id": "tenant-1", + "name": "Analyst", + "scope": AgentScope.ROSTER.value, + "source": AgentSource.WORKFLOW.value, + "status": AgentStatus.ACTIVE.value, + }, + ) + + +def test_current_snapshot_stores_agent_soul_snapshot_as_long_text_json(): + config_snapshot = AgentSoulConfig.model_validate( + { + "schema_version": 1, + "prompt": {"system_prompt": "You are a proposal analysis agent."}, + "env": {"secret_refs": [{"provider_credential_id": "cred-1"}]}, + } + ) + version = AgentConfigSnapshot( + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=config_snapshot, + ) + + config_snapshot_column = AgentConfigSnapshot.__table__.c.config_snapshot + assert isinstance(config_snapshot_column.type, JSONModelColumn) + assert config_snapshot_column.server_default is None + assert version.config_snapshot_dict == config_snapshot.model_dump(mode="json") + assert version.config_snapshot_dict["env"]["secret_refs"][0]["provider_credential_id"] == "cred-1" + + +def test_workflow_binding_stores_node_job_config_separately_from_agent_soul(): + node_job_config = { + "schema_version": 1, + "workflow_prompt": "Review the bid and identify clarification questions.", + "previous_node_output_refs": [{"node_id": "start", "output": "rfp"}], + "declared_outputs": [{"name": "questions", "type": "array"}], + } + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="agent-node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + node_job_config=json.dumps(node_job_config), + ) + + node_job_config_column = WorkflowAgentNodeBinding.__table__.c.node_job_config + assert isinstance(node_job_config_column.type, JSONModelColumn) + assert node_job_config_column.server_default is None + assert binding.node_job_config_dict == node_job_config + assert "prompt" not in binding.node_job_config_dict + + +def test_long_text_columns_do_not_use_mysql_incompatible_server_defaults(): + for column in (Agent.__table__.c.description,): + assert isinstance(column.type, LongText) + assert column.server_default is None + assert AgentConfigSnapshot.__table__.c.config_snapshot.server_default is None + assert WorkflowAgentNodeBinding.__table__.c.node_job_config.server_default is None + + +def test_agent_config_revision_links_previous_and_current_snapshots(): + revision = AgentConfigRevision( + tenant_id="tenant-1", + agent_id="agent-1", + previous_snapshot_id="version-0", + current_snapshot_id="version-1", + revision=2, + operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, + ) + + revision_table = cast(sa.Table, AgentConfigRevision.__table__) + unique_constraints = { + str(constraint.name): tuple(column.name for column in constraint.columns) + for constraint in revision_table.constraints + if isinstance(constraint, sa.UniqueConstraint) + } + + assert unique_constraints["agent_config_revision_agent_revision_unique"] == ( + "agent_id", + "revision", + ) + assert revision.previous_snapshot_id == "version-0" + assert revision.current_snapshot_id == "version-1" diff --git a/api/tests/unit_tests/models/test_types.py b/api/tests/unit_tests/models/test_types.py new file mode 100644 index 00000000000000..01508b9e0198cb --- /dev/null +++ b/api/tests/unit_tests/models/test_types.py @@ -0,0 +1,65 @@ +from typing import cast + +import pytest +from pydantic import BaseModel +from sqlalchemy.dialects import mysql, postgresql, sqlite +from sqlalchemy.dialects.mysql import LONGTEXT +from sqlalchemy.sql.sqltypes import TEXT + +from models.types import JSONModelColumn + + +class JsonColumnSample(BaseModel): + name: str + count: int = 0 + + +class NotPydanticModel: + pass + + +def test_json_model_column_serializes_supported_input_shapes(): + column = JSONModelColumn(JsonColumnSample) + dialect = sqlite.dialect() + + assert column.process_bind_param(None, dialect) is None + assert column.process_bind_param(JsonColumnSample(name="model", count=2), dialect) == '{"count":2,"name":"model"}' + assert column.process_bind_param({"name": "dict", "count": 3}, dialect) == '{"count":3,"name":"dict"}' + assert column.process_bind_param('{"name":"json","count":4}', dialect) == '{"count":4,"name":"json"}' + + +def test_json_model_column_deserializes_empty_and_json_values(): + column = JSONModelColumn(JsonColumnSample) + dialect = sqlite.dialect() + + assert column.process_result_value(None, dialect) is None + assert column.process_result_value("", dialect) is None + assert column.process_result_value('{"name":"stored","count":5}', dialect) == JsonColumnSample( + name="stored", + count=5, + ) + + +def test_json_model_column_keeps_model_class_directly(): + column = JSONModelColumn(JsonColumnSample) + + assert column.process_bind_param({"name": "class", "count": 6}, sqlite.dialect()) == '{"count":6,"name":"class"}' + assert column._model_class is JsonColumnSample + + +def test_json_model_column_rejects_non_pydantic_model_class(): + with pytest.raises(TypeError, match="must be a Pydantic BaseModel subclass"): + JSONModelColumn(cast(type[BaseModel], NotPydanticModel)) + + +def test_json_model_column_uses_long_text_compatible_dialect_types(): + column = JSONModelColumn(JsonColumnSample) + + assert isinstance(column.load_dialect_impl(postgresql.dialect()), TEXT) + assert isinstance(column.load_dialect_impl(sqlite.dialect()), TEXT) + assert isinstance(column.load_dialect_impl(mysql.dialect()), LONGTEXT) + + +def test_json_model_column_rejects_string_model_paths(): + with pytest.raises(TypeError): + JSONModelColumn(cast(type[BaseModel], "tests.unit_tests.models.test_types.JsonColumnSample")) diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py new file mode 100644 index 00000000000000..4cbc9cad8c4256 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -0,0 +1,143 @@ +import pytest + +from models.agent_config_entities import AgentKnowledgeQueryMode, DeclaredOutputType +from services.agent.composer_service import AgentComposerService +from services.agent.composer_validator import ComposerConfigValidator +from services.agent.errors import AgentSoulLockedError, PlaintextSecretNotAllowedError +from services.entities.agent_entities import ( + AgentSoulConfig, + ComposerSavePayload, + ComposerSaveStrategy, + ComposerVariant, + WorkflowNodeJobConfig, +) + + +def test_workflow_variant_rejects_agent_app_only_fields(): + with pytest.raises(ValueError): + ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY, + "agent_soul": { + "app_variables": [{"name": "company_name", "type": "string"}], + }, + } + ) + + +def test_agent_app_variant_rejects_workflow_node_job(): + with pytest.raises(ValueError): + ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + "node_job": {"workflow_prompt": "Use the previous node output."}, + } + ) + + +def test_locked_workflow_soul_rejects_soul_changes(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + "soul_lock": {"locked": True}, + "agent_soul": {"prompt": {"system_prompt": "changed"}}, + } + ) + + with pytest.raises(AgentSoulLockedError): + ComposerConfigValidator.validate_save_payload(payload) + + +def test_agent_app_soul_allows_app_features_and_variables(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + "agent_soul": { + "app_features": { + "conversation_opener": {}, + "follow_up": {}, + "citations_and_attributions": {}, + "content_moderation": {}, + "annotation_reply": {}, + }, + "app_variables": [{"name": "company_name", "type": "string", "required": True}], + }, + } + ) + + ComposerConfigValidator.validate_save_payload(payload) + assert payload.agent_soul is not None + assert payload.agent_soul.app_variables[0].name == "company_name" + + +def test_knowledge_query_mode_uses_stable_backend_enums(): + config = AgentSoulConfig.model_validate( + { + "knowledge": { + "datasets": [{"dataset_id": "dataset-1"}], + "query_mode": "generated_query", + "query_config": {"generation_prompt": "Create a retrieval query."}, + } + } + ) + + assert config.knowledge.query_mode == AgentKnowledgeQueryMode.GENERATED_QUERY + + +def test_declared_outputs_support_file_check_and_failure_strategy(): + node_job = WorkflowNodeJobConfig.model_validate( + { + "declared_outputs": [ + { + "name": "analysis_report", + "type": "file", + "file": {"extensions": [".pdf"], "mime_types": ["application/pdf"]}, + "checks": [ + { + "type": "benchmark_file", + "prompt": "Report must include risk summary.", + "benchmark_file_ref": {"upload_file_id": "file-1"}, + } + ], + "failure_strategy": { + "on_type_check_failed": "fail_node", + "on_output_check_failed": "retry", + "max_retries": 1, + }, + } + ] + } + ) + + output = node_job.declared_outputs[0] + assert output.type == DeclaredOutputType.FILE + assert output.file is not None + assert output.file.extensions == [".pdf"] + assert output.checks[0].type == "benchmark_file" + assert output.failure_strategy is not None + assert output.failure_strategy.max_retries == 1 + + +def test_plaintext_secrets_are_rejected(): + config = AgentSoulConfig.model_validate({"env": {"variables": [{"name": "OPENAI_API_KEY", "api_key": "secret"}]}}) + + with pytest.raises(PlaintextSecretNotAllowedError): + ComposerConfigValidator.validate_agent_soul(config) + + +def test_workflow_agent_soul_config_strips_agent_app_only_fields(): + config = AgentComposerService._workflow_agent_soul_config( + { + "prompt": {"system_prompt": "answer carefully"}, + "app_features": {"conversation_opener": {"enabled": True}}, + "app_variables": [{"name": "company_name", "type": "string"}], + } + ) + + assert config["prompt"]["system_prompt"] == "answer carefully" + assert config["app_features"] == {} + assert config["app_variables"] == [] diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py new file mode 100644 index 00000000000000..00840640a1a685 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -0,0 +1,575 @@ +from types import SimpleNamespace + +import pytest + +from models.agent import ( + Agent, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentKind, + AgentScope, + AgentSource, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) +from services.agent import composer_service, roster_service +from services.agent.composer_service import AgentComposerService +from services.agent.composer_validator import ComposerConfigValidator +from services.agent.errors import InvalidComposerConfigError +from services.agent.roster_service import AgentRosterService +from services.entities.agent_entities import AgentSoulConfig, ComposerSavePayload, ComposerSaveStrategy, ComposerVariant + + +class FakeScalarResult: + def __init__(self, values): + self.values = values + + def all(self): + return self.values + + +class FakeSession: + def __init__(self, *, scalars=None, scalar=None): + self._scalars = list(scalars or []) + self._scalar = list(scalar or []) + self.added = [] + self.commits = 0 + self.flushes = 0 + self.rollbacks = 0 + + def scalar(self, _stmt): + if self._scalar: + return self._scalar.pop(0) + return None + + def scalars(self, _stmt): + if self._scalars: + return FakeScalarResult(self._scalars.pop(0)) + return FakeScalarResult([]) + + def add(self, value): + self.added.append(value) + + def flush(self): + self.flushes += 1 + for index, value in enumerate(self.added, start=1): + if getattr(value, "id", None) is None: + value.id = f"generated-{index}" + + def commit(self): + self.commits += 1 + + def rollback(self): + self.rollbacks += 1 + + +def test_load_workflow_composer_returns_empty_state(monkeypatch): + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None) + + result = AgentComposerService.load_workflow_composer(tenant_id="tenant-1", app_id="app-1", node_id="node-1") + + assert result["binding"] is None + assert result["save_options"] == ["node_job_only", "save_to_roster"] + assert result["workflow_id"] == "workflow-1" + + +def test_load_workflow_composer_serializes_existing_binding(monkeypatch): + binding = SimpleNamespace(agent_id="agent-1", current_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, "_get_agent_if_present", lambda **kwargs: SimpleNamespace(id="agent-1")) + monkeypatch.setattr( + AgentComposerService, + "_get_version_if_present", + lambda **kwargs: SimpleNamespace(id="version-1"), + ) + monkeypatch.setattr( + AgentComposerService, + "_serialize_workflow_state", + lambda **kwargs: {"agent": kwargs["agent"].id, "version": kwargs["version"].id}, + ) + + result = AgentComposerService.load_workflow_composer(tenant_id="tenant-1", app_id="app-1", node_id="node-1") + + assert result == {"agent": "agent-1", "version": "version-1"} + + +@pytest.mark.parametrize( + ("strategy", "helper_name"), + [ + (ComposerSaveStrategy.NODE_JOB_ONLY, "_save_node_job_only"), + (ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, "_save_to_current_version"), + (ComposerSaveStrategy.SAVE_AS_NEW_VERSION, "_save_as_new_version"), + (ComposerSaveStrategy.SAVE_AS_NEW_AGENT, "_save_as_new_agent"), + (ComposerSaveStrategy.SAVE_TO_ROSTER, "_save_to_roster"), + ], +) +def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy, helper_name): + fake_session = FakeSession() + binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="version-1") + calls = [] + + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None) + monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: SimpleNamespace(id="agent-1")) + monkeypatch.setattr( + AgentComposerService, + "_get_version_if_present", + lambda **kwargs: SimpleNamespace(id="version-1"), + ) + monkeypatch.setattr(AgentComposerService, "_serialize_workflow_state", lambda **kwargs: {"state": "ok"}) + + def save_helper(**kwargs): + calls.append(kwargs) + return binding + + monkeypatch.setattr(AgentComposerService, helper_name, save_helper) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": strategy.value, + "agent_soul": {"prompt": {"system_prompt": "x"}}, + } + ) + + result = AgentComposerService.save_workflow_composer( + tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", payload=payload + ) + + assert result == {"state": "ok"} + assert calls + assert fake_session.commits == 1 + + +def test_save_workflow_composer_rejects_agent_app_variant(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP.value, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, + "agent_soul": {"prompt": {"system_prompt": "x"}}, + } + ) + + with pytest.raises(ValueError): + AgentComposerService.save_workflow_composer( + tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", payload=payload + ) + + +def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch): + fake_session = FakeSession(scalar=[None]) + created_version = SimpleNamespace(id="version-1") + + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr(AgentComposerService, "_create_config_version", lambda **kwargs: created_version) + monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True}) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP.value, + "save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value, + "new_agent_name": "Analyst", + "agent_soul": {"prompt": {"system_prompt": "x"}}, + } + ) + + result = AgentComposerService.save_agent_app_composer( + tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload + ) + + assert result == {"loaded": True} + assert fake_session.added[0].name == "Analyst" + assert fake_session.added[0].active_config_snapshot_id == "version-1" + assert fake_session.commits == 1 + + +def test_save_agent_app_composer_updates_current_version(monkeypatch): + fake_session = FakeSession( + scalar=[SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1", updated_by=None)] + ) + updated = {} + + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: SimpleNamespace(id="version-1")) + monkeypatch.setattr( + AgentComposerService, + "_update_current_version", + lambda **kwargs: updated.update(kwargs) or SimpleNamespace(id="version-2"), + ) + monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True}) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP.value, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, + "agent_soul": {"prompt": {"system_prompt": "updated"}}, + } + ) + + result = AgentComposerService.save_agent_app_composer( + tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload + ) + + assert result == {"loaded": True} + assert updated["operation"].value == "save_current_version" + assert fake_session._scalar == [] + assert fake_session.commits == 1 + + +def test_agent_app_composer_candidates_and_impact(monkeypatch): + bindings = [ + SimpleNamespace(app_id="app-1", workflow_id="workflow-1", node_id="node-1"), + SimpleNamespace(app_id="app-1", workflow_id="workflow-1", node_id="node-2"), + ] + monkeypatch.setattr(composer_service.db, "session", FakeSession(scalars=[bindings])) + + workflow_candidates = AgentComposerService.get_workflow_candidates(app_id="app-1") + agent_app_candidates = AgentComposerService.get_agent_app_candidates(app_id="app-1") + impact = AgentComposerService.calculate_impact(tenant_id="tenant-1", current_snapshot_id="version-1") + + assert workflow_candidates["variant"] == "workflow" + assert agent_app_candidates["variant"] == "agent_app" + assert impact["workflow_node_count"] == 2 + assert impact["bindings"][1]["node_id"] == "node-2" + + +def test_serialize_workflow_state_changes_lock_and_save_options(monkeypatch): + binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + workflow_id="workflow-1", + node_id="node-1", + node_job_config='{"workflow_prompt":"do work"}', + ) + agent = Agent(id="agent-1", name="Analyst", description="", scope=AgentScope.ROSTER, status=AgentStatus.ACTIVE) + version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}') + monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1}) + + state = AgentComposerService._serialize_workflow_state(binding=binding, agent=agent, version=version) + + assert state["soul_lock"]["locked"] is True + assert "save_as_new_version" in state["save_options"] + assert state["agent_soul"]["app_features"] == {} + + +def test_composer_save_helpers_create_and_rebind_agents(monkeypatch): + 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") + monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent) + monkeypatch.setattr(AgentComposerService, "_create_roster_agent_for_composer", lambda **kwargs: roster_agent) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + monkeypatch.setattr( + AgentComposerService, + "_require_version", + lambda **kwargs: AgentConfigSnapshot( + id="source-version-1", + tenant_id="tenant-1", + agent_id="roster-agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ), + ) + monkeypatch.setattr( + AgentComposerService, + "_create_config_version", + lambda **kwargs: AgentConfigSnapshot(id="new-version-1", version=2), + ) + + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "agent_soul": {"prompt": {"system_prompt": "new"}}, + "node_job": {"workflow_prompt": "use prior output"}, + "new_agent_name": "Copied Agent", + } + ) + existing_binding = WorkflowAgentNodeBinding(agent_id="inline-agent-1", current_snapshot_id="inline-version-1") + + updated_binding = AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=existing_binding, + payload=payload, + ) + inline_binding = AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-2", + account_id="account-1", + binding=None, + payload=payload, + ) + new_agent_binding = AgentComposerService._save_as_new_agent( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-3", + account_id="account-1", + binding=None, + payload=payload, + ) + save_to_roster_binding = AgentComposerService._save_to_roster( + tenant_id="tenant-1", + account_id="account-1", + binding=WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-4", + agent_id="inline-agent-1", + current_snapshot_id="inline-version-1", + ), + payload=payload, + ) + new_version_binding = AgentComposerService._save_as_new_version( + tenant_id="tenant-1", + account_id="account-1", + binding=WorkflowAgentNodeBinding(agent_id="roster-agent-1", current_snapshot_id="source-version-1"), + payload=payload, + ) + + assert updated_binding.updated_by == "account-1" + assert inline_binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT + assert inline_binding.agent_id == "inline-agent-1" + 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" + + +def test_composer_version_helpers_and_lookup_errors(monkeypatch): + fake_session = FakeSession( + scalar=[ + 1, + 3, + 2, + 4, + SimpleNamespace(id="workflow-1"), + None, + SimpleNamespace(id="agent-1"), + None, + SimpleNamespace(id="version-1"), + None, + ] + ) + monkeypatch.setattr(composer_service.db, "session", fake_session) + agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "new"}}) + + version = AgentComposerService._create_config_version( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + agent_soul=agent_soul, + operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION, + version_note="note", + ) + updated_snapshot = AgentComposerService._update_current_version( + current_snapshot=AgentConfigSnapshot( + id="version-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ), + account_id="account-1", + agent_soul=agent_soul, + operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, + version_note="updated", + ) + workflow = AgentComposerService._get_draft_workflow(tenant_id="tenant-1", app_id="app-1") + + with pytest.raises(ValueError): + AgentComposerService._get_draft_workflow(tenant_id="tenant-1", app_id="missing") + assert AgentComposerService._require_agent(tenant_id="tenant-1", agent_id="agent-1").id == "agent-1" + with pytest.raises(composer_service.AgentNotFoundError): + AgentComposerService._require_agent(tenant_id="tenant-1", agent_id=None) + assert AgentComposerService._get_agent_if_present(tenant_id="tenant-1", agent_id="agent-1") is None + assert ( + AgentComposerService._require_version(tenant_id="tenant-1", agent_id="agent-1", version_id="version-1").id + == "version-1" + ) + with pytest.raises(composer_service.AgentVersionNotFoundError): + AgentComposerService._require_version(tenant_id="tenant-1", agent_id="agent-1", version_id="missing") + + assert version.version == 2 + assert updated_snapshot.version == 3 + assert workflow.id == "workflow-1" + + +def test_composer_current_version_and_error_paths(monkeypatch): + fake_session = FakeSession(scalar=[2]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, + "agent_soul": {"prompt": {"system_prompt": "updated"}}, + "node_job": {"workflow_prompt": "job"}, + } + ) + binding = WorkflowAgentNodeBinding(agent_id="agent-1", current_snapshot_id="version-1") + version = AgentConfigSnapshot( + id="version-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ) + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: version) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: SimpleNamespace(updated_by=None)) + + result = AgentComposerService._save_to_current_version( + tenant_id="tenant-1", account_id="account-1", binding=binding, payload=payload + ) + + assert result.updated_by == "account-1" + assert result.current_snapshot_id != "version-1" + with pytest.raises(ValueError): + AgentComposerService._require_binding(None) + with pytest.raises(ValueError): + AgentComposerService._save_as_new_agent( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=None, + payload=ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_AGENT.value, + } + ), + ) + + +def test_roster_list_and_invite_options(monkeypatch): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Analyst", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + ) + version = AgentConfigSnapshot(id="version-1", agent_id="agent-1", version=1) + agent.active_config_snapshot_id = "version-1" + fake_session = FakeSession( + scalar=[1, 1, SimpleNamespace(id="workflow-1")], + scalars=[[agent], [agent], [SimpleNamespace(agent_id="agent-1", node_id="node-1")]], + ) + service = AgentRosterService(fake_session) + monkeypatch.setattr(service, "_load_versions_by_id", lambda version_ids: {"version-1": version}) + + listed = service.list_roster_agents(tenant_id="tenant-1", page=1, limit=20) + invited = service.list_invite_options(tenant_id="tenant-1", page=1, limit=20, app_id="app-1") + + assert listed["data"][0]["active_config_snapshot"]["id"] == "version-1" + assert invited["data"][0]["is_in_current_workflow"] is True + assert invited["data"][0]["existing_node_ids"] == ["node-1"] + + +def test_roster_update_archive_versions_and_detail(monkeypatch): + listed_version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2) + fake_session = FakeSession(scalars=[[listed_version]]) + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Analyst", + description="old", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + ) + version = AgentConfigSnapshot(id="version-1", agent_id="agent-1", version=1, config_snapshot='{"prompt":{}}') + + service = AgentRosterService(fake_session) + monkeypatch.setattr(service, "_get_agent", lambda **kwargs: agent) + monkeypatch.setattr(service, "_get_version", lambda **kwargs: version) + monkeypatch.setattr( + service, + "get_roster_agent_detail", + lambda **kwargs: {"id": kwargs["agent_id"], "description": agent.description}, + ) + + updated = service.update_roster_agent( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + payload=roster_service.RosterAgentUpdatePayload(description="new"), + ) + service.archive_roster_agent(tenant_id="tenant-1", agent_id="agent-1", account_id="account-1") + versions = service.list_agent_versions(tenant_id="tenant-1", agent_id="agent-1") + detail = service.get_agent_version_detail(tenant_id="tenant-1", agent_id="agent-1", version_id="version-1") + + assert updated["description"] == "new" + assert agent.status == AgentStatus.ARCHIVED + assert versions[0]["id"] == "version-2" + assert detail["config_snapshot"] == {"prompt": {}} + + +def test_roster_create_detail_and_lookup_helpers(monkeypatch): + fake_session = FakeSession( + scalar=[ + SimpleNamespace(id="agent-1"), + None, + SimpleNamespace(id="version-1"), + None, + ], + scalars=[[AgentConfigSnapshot(id="version-1", agent_id="agent-1", version=1)]], + ) + service = AgentRosterService(fake_session) + payload = roster_service.RosterAgentCreatePayload( + name="Analyst", + description="desc", + icon_type="emoji", + icon="A", + icon_background="#fff", + agent_soul=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "x"}}), + version_note="initial", + ) + + created = service.create_roster_agent(tenant_id="tenant-1", account_id="account-1", payload=payload) + found_agent = service._get_agent(tenant_id="tenant-1", agent_id="agent-1") + with pytest.raises(roster_service.AgentNotFoundError): + service._get_agent(tenant_id="tenant-1", agent_id="missing") + found_version = service._get_version(tenant_id="tenant-1", agent_id="agent-1", version_id="version-1") + with pytest.raises(roster_service.AgentVersionNotFoundError): + service._get_version(tenant_id="tenant-1", agent_id="agent-1", version_id=None) + loaded_versions = service._load_versions_by_id(["version-1"]) + assert service._load_versions_by_id([]) == {} + + assert created.name == "Analyst" + assert created.active_config_snapshot_id is not None + assert found_agent.id == "agent-1" + assert found_version.id == "version-1" + assert loaded_versions["version-1"].agent_id == "agent-1" + + +def test_validator_dict_helpers_wrap_validation_errors(): + valid_soul = ComposerConfigValidator.validate_agent_soul_dict({"prompt": {"system_prompt": "x"}}) + valid_node_job = ComposerConfigValidator.validate_node_job_dict({"workflow_prompt": "x"}) + + with pytest.raises(InvalidComposerConfigError): + ComposerConfigValidator.validate_agent_soul_dict({"prompt": "not-a-dict"}) + with pytest.raises(InvalidComposerConfigError): + ComposerConfigValidator.validate_node_job_dict({"declared_outputs": [{"type": "string"}]}) + + assert valid_soul.prompt.system_prompt == "x" + assert valid_node_job.workflow_prompt == "x" diff --git a/api/uv.lock b/api/uv.lock index 5ffda81f391fc0..06ae3a88ec261b 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1332,6 +1332,7 @@ dependencies = [ { name = "boto3" }, { name = "celery" }, { name = "croniter" }, + { name = "dify-agent" }, { name = "fastopenapi", extra = ["flask"] }, { name = "flask" }, { name = "flask-compress" }, @@ -1372,7 +1373,6 @@ dev = [ { name = "boto3-stubs" }, { name = "celery-types" }, { name = "coverage" }, - { name = "dify-agent" }, { name = "dotenv-linter" }, { name = "faker" }, { name = "hypothesis" }, @@ -1615,6 +1615,7 @@ requires-dist = [ { name = "boto3", specifier = ">=1.43.6" }, { name = "celery", specifier = ">=5.6.3" }, { name = "croniter", specifier = ">=6.2.2" }, + { name = "dify-agent", directory = "../dify-agent" }, { name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" }, { name = "flask", specifier = ">=3.1.3,<4.0.0" }, { name = "flask-compress", specifier = ">=1.24,<2.0.0" }, @@ -1655,7 +1656,6 @@ dev = [ { name = "boto3-stubs", specifier = ">=1.43.2" }, { name = "celery-types", specifier = ">=0.23.0" }, { name = "coverage", specifier = ">=7.13.4" }, - { name = "dify-agent", directory = "../dify-agent" }, { name = "dotenv-linter", specifier = ">=0.7.0" }, { name = "faker", specifier = ">=40.15.0" }, { name = "hypothesis", specifier = ">=6.152.4" }, diff --git a/dify-agent/src/dify_agent/client/_client.py b/dify-agent/src/dify_agent/client/_client.py index 7760f356aae60f..1a27381bc0d262 100644 --- a/dify-agent/src/dify_agent/client/_client.py +++ b/dify-agent/src/dify_agent/client/_client.py @@ -23,6 +23,8 @@ from pydantic import BaseModel, ValidationError from dify_agent.protocol.schemas import ( + CancelRunRequest, + CancelRunResponse, CreateRunRequest, CreateRunResponse, RUN_EVENT_ADAPTER, @@ -32,8 +34,8 @@ ) _ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel) -_TERMINAL_EVENT_TYPES = {"run_succeeded", "run_failed"} -_TERMINAL_RUN_STATUSES = {"succeeded", "failed"} +_TERMINAL_EVENT_TYPES = {"run_succeeded", "run_failed", "run_cancelled"} +_TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled"} class DifyAgentClientError(RuntimeError): @@ -279,6 +281,42 @@ def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse: raise DifyAgentClientError(f"create_run_sync request failed: {exc}") from exc return _parse_model_response(response, CreateRunResponse) + async def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse: + """Request explicit cancellation for ``run_id``. + + The server may accept cancellation only for active runs; unsupported + deployments return an HTTP error rather than overloading ``run_failed``. + """ + request_model = request or CancelRunRequest() + try: + response = await self._get_async_http_client().post( + self._url(f"/runs/{quote(run_id, safe='')}/cancel"), + content=request_model.model_dump_json(), + headers=self._merged_headers({"Content-Type": "application/json"}), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError("cancel_run timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"cancel_run request failed: {exc}") from exc + return _parse_model_response(response, CancelRunResponse) + + def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse: + """Synchronous variant of ``cancel_run``.""" + request_model = request or CancelRunRequest() + try: + response = self._get_sync_http_client().post( + self._url(f"/runs/{quote(run_id, safe='')}/cancel"), + content=request_model.model_dump_json(), + headers=self._merged_headers({"Content-Type": "application/json"}), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError("cancel_run_sync timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"cancel_run_sync request failed: {exc}") from exc + return _parse_model_response(response, CancelRunResponse) + async def get_run(self, run_id: str) -> RunStatusResponse: """Return the current status for ``run_id`` or raise a mapped client error.""" try: diff --git a/dify-agent/src/dify_agent/protocol/__init__.py b/dify-agent/src/dify_agent/protocol/__init__.py index 7ab78a5e4d8e7d..3ada378df88461 100644 --- a/dify-agent/src/dify_agent/protocol/__init__.py +++ b/dify-agent/src/dify_agent/protocol/__init__.py @@ -5,17 +5,26 @@ DIFY_AGENT_OUTPUT_LAYER_ID, RUN_EVENT_ADAPTER, BaseRunEvent, + CancelRunRequest, + CancelRunResponse, CreateRunRequest, CreateRunResponse, EmptyRunEventData, + ExecutionContext, + InvokeFrom, LayerExitSignals, PydanticAIStreamRunEvent, + RunCancelledEvent, + RunCancelledEventData, RunEvent, RunComposition, RunEventType, RunEventsResponse, RunFailedEvent, RunFailedEventData, + RunPausedEvent, + RunPausedEventData, + RunPurpose, RunLayerSpec, RunStartedEvent, RunStatus, @@ -28,20 +37,29 @@ __all__ = [ "BaseRunEvent", + "CancelRunRequest", + "CancelRunResponse", "CreateRunRequest", "CreateRunResponse", "DIFY_AGENT_MODEL_LAYER_ID", "DIFY_AGENT_OUTPUT_LAYER_ID", "EmptyRunEventData", + "ExecutionContext", + "InvokeFrom", "LayerExitSignals", "PydanticAIStreamRunEvent", "RUN_EVENT_ADAPTER", + "RunCancelledEvent", + "RunCancelledEventData", "RunComposition", "RunEvent", "RunEventType", "RunEventsResponse", "RunFailedEvent", "RunFailedEventData", + "RunPausedEvent", + "RunPausedEventData", + "RunPurpose", "RunLayerSpec", "RunStartedEvent", "RunStatus", diff --git a/dify-agent/src/dify_agent/protocol/schemas.py b/dify-agent/src/dify_agent/protocol/schemas.py index 430c4052a3f1e7..76890e3991aa4c 100644 --- a/dify-agent/src/dify_agent/protocol/schemas.py +++ b/dify-agent/src/dify_agent/protocol/schemas.py @@ -43,12 +43,16 @@ DIFY_AGENT_MODEL_LAYER_ID: Final[str] = "llm" DIFY_AGENT_OUTPUT_LAYER_ID: Final[str] = "output" -RunStatus = Literal["running", "succeeded", "failed"] +RunStatus = Literal["running", "paused", "succeeded", "failed", "cancelled"] +RunPurpose = Literal["workflow_node", "single_step", "agent_app", "babysit", "fasten_preview"] +InvokeFrom = Literal["workflow_run", "single_step", "agent_app", "babysit", "fasten"] RunEventType = Literal[ "run_started", "pydantic_ai_event", + "run_paused", "run_succeeded", "run_failed", + "run_cancelled", ] @@ -100,6 +104,29 @@ class RunComposition(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") +class ExecutionContext(BaseModel): + """Dify-owned execution identifiers attached to one Agent backend run. + + The Agent backend stores and replays this context for observability and + product correlation only. It must not use these identifiers as authorization + proof; API backend remains responsible for tenant and user access checks. + """ + + tenant_id: str + app_id: str | None = None + workflow_id: str | None = None + workflow_run_id: str | None = None + node_id: str | None = None + node_execution_id: str | None = None + conversation_id: str | None = None + agent_id: str | None = None + agent_config_version_id: str | None = None + invoke_from: InvokeFrom + trace_id: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + class CreateRunRequest(BaseModel): """Request body for creating one async agent run. @@ -115,12 +142,30 @@ class CreateRunRequest(BaseModel): """ composition: RunComposition + execution_context: ExecutionContext | None = None + purpose: RunPurpose = "workflow_node" + idempotency_key: str | None = None + metadata: dict[str, JsonValue] = Field(default_factory=dict) session_snapshot: CompositorSessionSnapshot | None = None on_exit: LayerExitSignals = Field(default_factory=LayerExitSignals) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") +class CancelRunRequest(BaseModel): + """Request body for cancelling a run. + + Runtime cancellation is intentionally a separate protocol operation from + failed execution so API callers can distinguish user/operator cancellation + from model, tool, or infrastructure failures. + """ + + reason: str | None = None + message: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + def normalize_composition(composition: RunComposition) -> tuple[CompositorConfig, dict[str, LayerConfigInput]]: """Split public Dify composition into Agenton's graph config and layer configs. @@ -159,6 +204,15 @@ class CreateRunResponse(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") +class CancelRunResponse(BaseModel): + """Response returned after a cancel request is accepted.""" + + run_id: str + status: Literal["cancelled"] + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + class RunStatusResponse(BaseModel): """Current server-side status for one run.""" @@ -195,6 +249,25 @@ class RunFailedEventData(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") +class RunPausedEventData(BaseModel): + """Pause payload used for human handoff or other resumable waits.""" + + reason: str + message: str | None = None + session_snapshot: CompositorSessionSnapshot | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class RunCancelledEventData(BaseModel): + """Terminal cancellation payload for explicit user/operator cancellation.""" + + reason: str | None = None + message: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + class BaseRunEvent(BaseModel): """Shared append-only event envelope visible through polling and SSE.""" @@ -233,8 +306,27 @@ class RunFailedEvent(BaseRunEvent): data: RunFailedEventData +class RunPausedEvent(BaseRunEvent): + """Resumable pause event emitted when a run waits for outside input.""" + + type: Literal["run_paused"] = "run_paused" + data: RunPausedEventData + + +class RunCancelledEvent(BaseRunEvent): + """Terminal cancellation event emitted after an explicit cancel request.""" + + type: Literal["run_cancelled"] = "run_cancelled" + data: RunCancelledEventData = Field(default_factory=RunCancelledEventData) + + RunEvent: TypeAlias = Annotated[ - RunStartedEvent | PydanticAIStreamRunEvent | RunSucceededEvent | RunFailedEvent, + RunStartedEvent + | PydanticAIStreamRunEvent + | RunPausedEvent + | RunSucceededEvent + | RunFailedEvent + | RunCancelledEvent, Field(discriminator="type"), ] RUN_EVENT_ADAPTER: TypeAdapter[RunEvent] = TypeAdapter(RunEvent) @@ -252,20 +344,29 @@ class RunEventsResponse(BaseModel): __all__ = [ "BaseRunEvent", + "CancelRunRequest", + "CancelRunResponse", "CreateRunRequest", "CreateRunResponse", "DIFY_AGENT_MODEL_LAYER_ID", "DIFY_AGENT_OUTPUT_LAYER_ID", "EmptyRunEventData", + "ExecutionContext", + "InvokeFrom", "LayerExitSignals", "PydanticAIStreamRunEvent", "RUN_EVENT_ADAPTER", + "RunCancelledEvent", + "RunCancelledEventData", "RunComposition", "RunEvent", "RunEventType", "RunEventsResponse", "RunFailedEvent", "RunFailedEventData", + "RunPausedEvent", + "RunPausedEventData", + "RunPurpose", "RunStartedEvent", "RunStatus", "RunStatusResponse", diff --git a/dify-agent/src/dify_agent/server/routes/runs.py b/dify-agent/src/dify_agent/server/routes/runs.py index 9375b1f5b7efae..a5dff092184da8 100644 --- a/dify-agent/src/dify_agent/server/routes/runs.py +++ b/dify-agent/src/dify_agent/server/routes/runs.py @@ -13,7 +13,14 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query from fastapi.responses import StreamingResponse -from dify_agent.protocol.schemas import CreateRunRequest, CreateRunResponse, RunEventsResponse, RunStatusResponse +from dify_agent.protocol.schemas import ( + CancelRunRequest, + CancelRunResponse, + CreateRunRequest, + CreateRunResponse, + RunEventsResponse, + RunStatusResponse, +) from dify_agent.runtime.run_scheduler import RunRequestValidationError, RunScheduler, SchedulerStoppingError from dify_agent.server.sse import sse_event_stream from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError @@ -59,6 +66,18 @@ async def get_run_status(run_id: str, store: Annotated[RedisRunStore, Depends(st error=record.error, ) + @router.post("/{run_id}/cancel", response_model=CancelRunResponse) + async def cancel_run(run_id: str, request: CancelRunRequest) -> CancelRunResponse: + """Reserve the cancellation endpoint in the public protocol. + + Runtime cancellation requires scheduler task lookup and persistence + semantics that are outside the current server implementation. Exposing a + typed endpoint now lets clients bind to the final route while receiving + an explicit 501 until execution support lands. + """ + del run_id, request + raise HTTPException(status_code=501, detail="run cancellation is not implemented") + @router.get("/{run_id}/events", response_model=RunEventsResponse) async def get_run_events( run_id: str, diff --git a/dify-agent/tests/local/dify_agent/client/test_client.py b/dify-agent/tests/local/dify_agent/client/test_client.py index 990475909db6bd..db2d2e2386ead0 100644 --- a/dify-agent/tests/local/dify_agent/client/test_client.py +++ b/dify-agent/tests/local/dify_agent/client/test_client.py @@ -20,8 +20,11 @@ DifyAgentValidationError, ) from dify_agent.protocol.schemas import ( + CancelRunRequest, + CancelRunResponse, CreateRunRequest, RUN_EVENT_ADAPTER, + RunCancelledEvent, RunEvent, RunEventsResponse, RunStartedEvent, @@ -97,6 +100,10 @@ def handler(request: httpx.Request) -> httpx.Response: "next_cursor": "1-0", }, ) + if request.method == "POST" and request.url.path == "/runs/run-1/cancel": + payload = cast(dict[str, object], json.loads(request.content)) + assert payload == {"reason": "user_cancelled", "message": None} + return httpx.Response(202, json={"run_id": "run-1", "status": "cancelled"}) raise AssertionError(f"unexpected request: {request.method} {request.url}") http_client = httpx.Client(transport=httpx.MockTransport(handler)) @@ -105,11 +112,14 @@ def handler(request: httpx.Request) -> httpx.Response: created = client.create_run_sync(CreateRunRequest.model_validate(_create_run_payload())) status = client.get_run_sync(created.run_id) events = client.get_events_sync(created.run_id, after="0-0", limit=10) + cancelled = client.cancel_run_sync(created.run_id, CancelRunRequest(reason="user_cancelled")) assert created.status == "running" assert status.status == "running" assert isinstance(events, RunEventsResponse) assert [event.type for event in events.events] == ["run_started"] + assert isinstance(cancelled, CancelRunResponse) + assert cancelled.status == "cancelled" def test_async_methods_and_wait_run_parse_protocol_dtos() -> None: @@ -251,6 +261,31 @@ def handler(_request: httpx.Request) -> httpx.Response: assert calls == 1 +def test_stream_events_stops_after_cancelled_terminal_event() -> None: + calls = 0 + body = "".join( + [ + _event_frame(RunStartedEvent(id="1-0", run_id="run-1")), + _event_frame(RunCancelledEvent(id="2-0", run_id="run-1")), + ] + ) + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(200, content=body) + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + events = list(client.stream_events_sync("run-1", reconnect_delay_seconds=0)) + + assert [event.type for event in events] == ["run_started", "run_cancelled"] + assert calls == 1 + + def test_stream_events_reconnects_from_latest_event_id() -> None: seen_after: list[str] = [] diff --git a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py index ffdad4207bd1e4..2cf0cabab19b85 100644 --- a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py +++ b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py @@ -12,12 +12,17 @@ from dify_agent.protocol.schemas import ( RUN_EVENT_ADAPTER, CreateRunRequest, + ExecutionContext, LayerExitSignals, PydanticAIStreamRunEvent, + RunCancelledEvent, + RunCancelledEventData, RunComposition, RunFailedEvent, RunFailedEventData, RunLayerSpec, + RunPausedEvent, + RunPausedEventData, RunStartedEvent, RunSucceededEvent, RunSucceededEventData, @@ -38,6 +43,15 @@ def test_run_event_adapter_round_trips_typed_variants() -> None: ), ), RunFailedEvent(run_id="run-1", data=RunFailedEventData(error="boom", reason="shutdown")), + RunPausedEvent( + run_id="run-1", + data=RunPausedEventData( + reason="human_handoff", + message="Need review", + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ), + RunCancelledEvent(run_id="run-1", data=RunCancelledEventData(reason="user_cancelled")), ] for event in events: @@ -89,6 +103,18 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ } ) request = CreateRunRequest( + execution_context=ExecutionContext( + tenant_id="tenant-1", + workflow_id="workflow-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + node_execution_id="node-execution-1", + invoke_from="workflow_run", + trace_id="trace-1", + ), + purpose="workflow_node", + idempotency_key="workflow-run-1:node-execution-1", + metadata={"source": "unit_test"}, composition=RunComposition( layers=[ RunLayerSpec(name="prompt", type=PLAIN_PROMPT_LAYER_TYPE_ID, config=prompt_config), @@ -105,12 +131,28 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ config=output_config, ), ] - ) + ), ) graph_config, layer_configs = normalize_composition(request.composition) payload = request.model_dump(mode="json") + assert payload["execution_context"] == { + "tenant_id": "tenant-1", + "app_id": None, + "workflow_id": "workflow-1", + "workflow_run_id": "workflow-run-1", + "node_id": "node-1", + "node_execution_id": "node-execution-1", + "conversation_id": None, + "agent_id": None, + "agent_config_version_id": None, + "invoke_from": "workflow_run", + "trace_id": "trace-1", + } + assert payload["purpose"] == "workflow_node" + assert payload["idempotency_key"] == "workflow-run-1:node-execution-1" + assert payload["metadata"] == {"source": "unit_test"} assert payload["composition"]["layers"][0]["config"] == {"prefix": "system", "user": "hello", "suffix": []} assert [layer.model_dump(mode="json") for layer in graph_config.layers] == [ {"name": "prompt", "type": PLAIN_PROMPT_LAYER_TYPE_ID, "deps": {}, "metadata": {}}, @@ -163,6 +205,17 @@ def test_on_exit_accept_layer_overrides() -> None: assert request.on_exit.layers == {"prompt": ExitIntent.SUSPEND, "llm": ExitIntent.DELETE} +def test_execution_context_rejects_unknown_fields() -> None: + with pytest.raises(ValidationError): + _ = ExecutionContext.model_validate( + { + "tenant_id": "tenant-1", + "invoke_from": "workflow_run", + "unknown": "value", + } + ) + + def test_layer_exit_signals_reject_extra_fields() -> None: with pytest.raises(ValidationError): _ = LayerExitSignals.model_validate({"default": "suspend", "unknown": "value"}) diff --git a/dify-agent/tests/local/dify_agent/server/test_runs_routes.py b/dify-agent/tests/local/dify_agent/server/test_runs_routes.py index c173816a511793..bed7883170c6c3 100644 --- a/dify-agent/tests/local/dify_agent/server/test_runs_routes.py +++ b/dify-agent/tests/local/dify_agent/server/test_runs_routes.py @@ -67,6 +67,21 @@ async def create_run(self, request: object) -> RunRecord: assert response.json() == {"run_id": "run-1", "status": "running"} +def test_cancel_run_endpoint_is_reserved_but_not_implemented() -> None: + from fastapi import FastAPI + + app = FastAPI() + app.include_router( + create_runs_router(lambda: FakeStore(), lambda: FakeScheduler()) # pyright: ignore[reportArgumentType] + ) + client = TestClient(app) + + response = client.post("/runs/run-1/cancel", json={"reason": "user_cancelled"}) + + assert response.status_code == 501 + assert response.json()["detail"] == "run cancellation is not implemented" + + def test_create_run_accepts_valid_full_plugin_graph() -> None: from fastapi import FastAPI diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 57ccc78cce0d59..03252105987591 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -8,14 +8,14 @@ Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`. -Are we OpenAPI ready? **No.** Current generated API contracts are **36.3% ready**. +Are we OpenAPI ready? **No.** Current generated API contracts are **35.4% ready**. | Surface | Ready | Not ready | Total | Ready % | | --------- | ------: | --------: | ------: | --------: | -| console | 205 | 365 | 570 | 36.0% | +| console | 205 | 383 | 588 | 34.9% | | service | 28 | 60 | 88 | 31.8% | | web | 21 | 20 | 41 | 51.2% | -| **total** | **254** | **445** | **699** | **36.3%** | +| **total** | **254** | **463** | **717** | **35.4%** | Readiness here means the generated contract operation is not marked with: diff --git a/packages/contracts/generated/api/console/agents/orpc.gen.ts b/packages/contracts/generated/api/console/agents/orpc.gen.ts new file mode 100644 index 00000000000000..9df6c64ea75381 --- /dev/null +++ b/packages/contracts/generated/api/console/agents/orpc.gen.ts @@ -0,0 +1,203 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { oc } from '@orpc/contract' +import * as z from 'zod' + +import { + zDeleteAgentsByAgentIdPath, + zDeleteAgentsByAgentIdResponse, + zGetAgentsByAgentIdPath, + zGetAgentsByAgentIdResponse, + zGetAgentsByAgentIdVersionsByVersionIdPath, + zGetAgentsByAgentIdVersionsByVersionIdResponse, + zGetAgentsByAgentIdVersionsPath, + zGetAgentsByAgentIdVersionsResponse, + zGetAgentsInviteOptionsResponse, + zGetAgentsResponse, + zPatchAgentsByAgentIdBody, + zPatchAgentsByAgentIdPath, + zPatchAgentsByAgentIdResponse, + zPostAgentsBody, + zPostAgentsResponse, +} from './zod.gen' + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentsInviteOptions', + path: '/agents/invite-options', + tags: ['console'], + }) + .output(zGetAgentsInviteOptionsResponse) + +export const inviteOptions = { + get, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get2 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentsByAgentIdVersionsByVersionId', + path: '/agents/{agent_id}/versions/{version_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentsByAgentIdVersionsByVersionIdPath })) + .output(zGetAgentsByAgentIdVersionsByVersionIdResponse) + +export const byVersionId = { + get: get2, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get3 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentsByAgentIdVersions', + path: '/agents/{agent_id}/versions', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentsByAgentIdVersionsPath })) + .output(zGetAgentsByAgentIdVersionsResponse) + +export const versions = { + get: get3, + byVersionId, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const delete_ = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentsByAgentId', + path: '/agents/{agent_id}', + tags: ['console'], + }) + .input(z.object({ params: zDeleteAgentsByAgentIdPath })) + .output(zDeleteAgentsByAgentIdResponse) + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get4 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentsByAgentId', + path: '/agents/{agent_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentsByAgentIdPath })) + .output(zGetAgentsByAgentIdResponse) + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const patch = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAgentsByAgentId', + path: '/agents/{agent_id}', + tags: ['console'], + }) + .input(z.object({ body: zPatchAgentsByAgentIdBody, params: zPatchAgentsByAgentIdPath })) + .output(zPatchAgentsByAgentIdResponse) + +export const byAgentId = { + delete: delete_, + get: get4, + patch, + versions, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get5 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgents', + path: '/agents', + tags: ['console'], + }) + .output(zGetAgentsResponse) + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgents', + path: '/agents', + tags: ['console'], + }) + .input(z.object({ body: zPostAgentsBody })) + .output(zPostAgentsResponse) + +export const agents = { + get: get5, + post, + inviteOptions, + byAgentId, +} + +export const contract = { + agents, +} diff --git a/packages/contracts/generated/api/console/agents/types.gen.ts b/packages/contracts/generated/api/console/agents/types.gen.ts new file mode 100644 index 00000000000000..10a784b9dd21b0 --- /dev/null +++ b/packages/contracts/generated/api/console/agents/types.gen.ts @@ -0,0 +1,255 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}/console/api` | (string & {}) +} + +export type RosterAgentCreatePayload = { + agent_soul?: AgentSoulConfig + description?: string + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType + name: string + version_note?: string | null +} + +export type RosterAgentUpdatePayload = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType + name?: string | null +} + +export type AgentSoulConfig = { + app_features?: { + [key: string]: unknown + } + app_variables?: Array + env?: AgentSoulEnvConfig + human?: AgentSoulHumanConfig + knowledge?: AgentSoulKnowledgeConfig + memory?: AgentSoulMemoryConfig + misc_legacy?: { + [key: string]: unknown + } + prompt?: AgentSoulPromptConfig + sandbox?: AgentSoulSandboxConfig + schema_version?: number + skills_files?: AgentSoulSkillsFilesConfig + tools?: AgentSoulToolsConfig +} + +export type AgentIconType = 'emoji' | 'image' | 'link' + +export type AppVariableConfig = { + default?: unknown + name: string + required?: boolean + type: string +} + +export type AgentSoulEnvConfig = { + secret_refs?: Array<{ + [key: string]: unknown + }> + variables?: Array<{ + [key: string]: unknown + }> +} + +export type AgentSoulHumanConfig = { + contacts?: Array<{ + [key: string]: unknown + }> + tools?: Array<{ + [key: string]: unknown + }> +} + +export type AgentSoulKnowledgeConfig = { + datasets?: Array<{ + [key: string]: unknown + }> + query_config?: { + [key: string]: unknown + } + query_mode?: AgentKnowledgeQueryMode +} + +export type AgentSoulMemoryConfig = { + artifacts?: Array<{ + [key: string]: unknown + }> + budget?: string | null + scope?: string | null +} + +export type AgentSoulPromptConfig = { + system_prompt?: string +} + +export type AgentSoulSandboxConfig = { + config?: { + [key: string]: unknown + } + provider?: string | null +} + +export type AgentSoulSkillsFilesConfig = { + files?: Array<{ + [key: string]: unknown + }> + skills?: Array<{ + [key: string]: unknown + }> +} + +export type AgentSoulToolsConfig = { + cli_tools?: Array<{ + [key: string]: unknown + }> + dify_tools?: Array<{ + [key: string]: unknown + }> +} + +export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' + +export type GetAgentsData = { + body?: never + path?: never + query?: never + url: '/agents' +} + +export type GetAgentsResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAgentsResponse = GetAgentsResponses[keyof GetAgentsResponses] + +export type PostAgentsData = { + body: RosterAgentCreatePayload + path?: never + query?: never + url: '/agents' +} + +export type PostAgentsResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostAgentsResponse = PostAgentsResponses[keyof PostAgentsResponses] + +export type GetAgentsInviteOptionsData = { + body?: never + path?: never + query?: never + url: '/agents/invite-options' +} + +export type GetAgentsInviteOptionsResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAgentsInviteOptionsResponse + = GetAgentsInviteOptionsResponses[keyof GetAgentsInviteOptionsResponses] + +export type DeleteAgentsByAgentIdData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agents/{agent_id}' +} + +export type DeleteAgentsByAgentIdResponses = { + 200: { + [key: string]: unknown + } +} + +export type DeleteAgentsByAgentIdResponse + = DeleteAgentsByAgentIdResponses[keyof DeleteAgentsByAgentIdResponses] + +export type GetAgentsByAgentIdData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agents/{agent_id}' +} + +export type GetAgentsByAgentIdResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAgentsByAgentIdResponse + = GetAgentsByAgentIdResponses[keyof GetAgentsByAgentIdResponses] + +export type PatchAgentsByAgentIdData = { + body: RosterAgentUpdatePayload + path: { + agent_id: string + } + query?: never + url: '/agents/{agent_id}' +} + +export type PatchAgentsByAgentIdResponses = { + 200: { + [key: string]: unknown + } +} + +export type PatchAgentsByAgentIdResponse + = PatchAgentsByAgentIdResponses[keyof PatchAgentsByAgentIdResponses] + +export type GetAgentsByAgentIdVersionsData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agents/{agent_id}/versions' +} + +export type GetAgentsByAgentIdVersionsResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAgentsByAgentIdVersionsResponse + = GetAgentsByAgentIdVersionsResponses[keyof GetAgentsByAgentIdVersionsResponses] + +export type GetAgentsByAgentIdVersionsByVersionIdData = { + body?: never + path: { + agent_id: string + version_id: string + } + query?: never + url: '/agents/{agent_id}/versions/{version_id}' +} + +export type GetAgentsByAgentIdVersionsByVersionIdResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAgentsByAgentIdVersionsByVersionIdResponse + = GetAgentsByAgentIdVersionsByVersionIdResponses[keyof GetAgentsByAgentIdVersionsByVersionIdResponses] diff --git a/packages/contracts/generated/api/console/agents/zod.gen.ts b/packages/contracts/generated/api/console/agents/zod.gen.ts new file mode 100644 index 00000000000000..dd9cabdffd66c6 --- /dev/null +++ b/packages/contracts/generated/api/console/agents/zod.gen.ts @@ -0,0 +1,197 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod' + +/** + * AgentIconType + * + * Supported icon storage formats for Agent roster entries. + */ +export const zAgentIconType = z.enum(['emoji', 'image', 'link']) + +/** + * RosterAgentUpdatePayload + */ +export const zRosterAgentUpdatePayload = z.object({ + description: z.string().nullish(), + icon: z.string().max(255).nullish(), + icon_background: z.string().max(255).nullish(), + icon_type: zAgentIconType.optional(), + name: z.string().min(1).max(255).nullish(), +}) + +/** + * AppVariableConfig + */ +export const zAppVariableConfig = z.object({ + default: z.unknown().optional(), + name: z.string().min(1).max(255), + required: z.boolean().optional().default(false), + type: z.string().min(1).max(64), +}) + +/** + * AgentSoulEnvConfig + */ +export const zAgentSoulEnvConfig = z.object({ + secret_refs: z.array(z.record(z.string(), z.unknown())).optional(), + variables: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +/** + * AgentSoulHumanConfig + */ +export const zAgentSoulHumanConfig = z.object({ + contacts: z.array(z.record(z.string(), z.unknown())).optional(), + tools: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +/** + * AgentSoulMemoryConfig + */ +export const zAgentSoulMemoryConfig = z.object({ + artifacts: z.array(z.record(z.string(), z.unknown())).optional(), + budget: z.string().nullish(), + scope: z.string().nullish(), +}) + +/** + * AgentSoulPromptConfig + */ +export const zAgentSoulPromptConfig = z.object({ + system_prompt: z.string().optional().default(''), +}) + +/** + * AgentSoulSandboxConfig + */ +export const zAgentSoulSandboxConfig = z.object({ + config: z.record(z.string(), z.unknown()).optional(), + provider: z.string().nullish(), +}) + +/** + * AgentSoulSkillsFilesConfig + */ +export const zAgentSoulSkillsFilesConfig = z.object({ + files: z.array(z.record(z.string(), z.unknown())).optional(), + skills: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +/** + * AgentSoulToolsConfig + */ +export const zAgentSoulToolsConfig = z.object({ + cli_tools: z.array(z.record(z.string(), z.unknown())).optional(), + dify_tools: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +/** + * AgentKnowledgeQueryMode + */ +export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']) + +/** + * AgentSoulKnowledgeConfig + */ +export const zAgentSoulKnowledgeConfig = z.object({ + datasets: z.array(z.record(z.string(), z.unknown())).optional(), + query_config: z.record(z.string(), z.unknown()).optional(), + query_mode: zAgentKnowledgeQueryMode.optional(), +}) + +/** + * AgentSoulConfig + */ +export const zAgentSoulConfig = z.object({ + app_features: z.record(z.string(), z.unknown()).optional(), + app_variables: z.array(zAppVariableConfig).optional(), + env: zAgentSoulEnvConfig.optional(), + human: zAgentSoulHumanConfig.optional(), + knowledge: zAgentSoulKnowledgeConfig.optional(), + memory: zAgentSoulMemoryConfig.optional(), + misc_legacy: z.record(z.string(), z.unknown()).optional(), + prompt: zAgentSoulPromptConfig.optional(), + sandbox: zAgentSoulSandboxConfig.optional(), + schema_version: z.int().optional().default(1), + skills_files: zAgentSoulSkillsFilesConfig.optional(), + tools: zAgentSoulToolsConfig.optional(), +}) + +/** + * RosterAgentCreatePayload + */ +export const zRosterAgentCreatePayload = z.object({ + agent_soul: zAgentSoulConfig.optional(), + description: z.string().optional().default(''), + icon: z.string().max(255).nullish(), + icon_background: z.string().max(255).nullish(), + icon_type: zAgentIconType.optional(), + name: z.string().min(1).max(255), + version_note: z.string().nullish(), +}) + +/** + * Success + */ +export const zGetAgentsResponse = z.record(z.string(), z.unknown()) + +export const zPostAgentsBody = zRosterAgentCreatePayload + +/** + * Success + */ +export const zPostAgentsResponse = z.record(z.string(), z.unknown()) + +/** + * Success + */ +export const zGetAgentsInviteOptionsResponse = z.record(z.string(), z.unknown()) + +export const zDeleteAgentsByAgentIdPath = z.object({ + agent_id: z.string(), +}) + +/** + * Success + */ +export const zDeleteAgentsByAgentIdResponse = z.record(z.string(), z.unknown()) + +export const zGetAgentsByAgentIdPath = z.object({ + agent_id: z.string(), +}) + +/** + * Success + */ +export const zGetAgentsByAgentIdResponse = z.record(z.string(), z.unknown()) + +export const zPatchAgentsByAgentIdBody = zRosterAgentUpdatePayload + +export const zPatchAgentsByAgentIdPath = z.object({ + agent_id: z.string(), +}) + +/** + * Success + */ +export const zPatchAgentsByAgentIdResponse = z.record(z.string(), z.unknown()) + +export const zGetAgentsByAgentIdVersionsPath = z.object({ + agent_id: z.string(), +}) + +/** + * Success + */ +export const zGetAgentsByAgentIdVersionsResponse = z.record(z.string(), z.unknown()) + +export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({ + agent_id: z.string(), + version_id: z.string(), +}) + +/** + * Success + */ +export const zGetAgentsByAgentIdVersionsByVersionIdResponse = z.record(z.string(), z.unknown()) diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 3e8bd4a6bb4a89..c3fdc93491532e 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -37,6 +37,10 @@ import { zGetAppsByAppIdAdvancedChatWorkflowRunsPath, zGetAppsByAppIdAdvancedChatWorkflowRunsQuery, zGetAppsByAppIdAdvancedChatWorkflowRunsResponse, + zGetAppsByAppIdAgentComposerCandidatesPath, + zGetAppsByAppIdAgentComposerCandidatesResponse, + zGetAppsByAppIdAgentComposerPath, + zGetAppsByAppIdAgentComposerResponse, zGetAppsByAppIdAgentLogsPath, zGetAppsByAppIdAgentLogsQuery, zGetAppsByAppIdAgentLogsResponse, @@ -153,6 +157,10 @@ import { zGetAppsByAppIdWorkflowsDraftConversationVariablesResponse, zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesPath, zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse, + zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath, + zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse, + zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath, + zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse, zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath, zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse, zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath, @@ -219,6 +227,9 @@ import { zPostAppsByAppIdAdvancedChatWorkflowsDraftRunBody, zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath, zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse, + zPostAppsByAppIdAgentComposerValidateBody, + zPostAppsByAppIdAgentComposerValidatePath, + zPostAppsByAppIdAgentComposerValidateResponse, zPostAppsByAppIdAnnotationReplyByActionBody, zPostAppsByAppIdAnnotationReplyByActionPath, zPostAppsByAppIdAnnotationReplyByActionResponse, @@ -325,6 +336,14 @@ import { zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody, zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath, zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterPath, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePath, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunBody, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunPath, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse, @@ -353,6 +372,9 @@ import { zPostAppsResponse, zPostAppsWorkflowsOnlineUsersBody, zPostAppsWorkflowsOnlineUsersResponse, + zPutAppsByAppIdAgentComposerBody, + zPutAppsByAppIdAgentComposerPath, + zPutAppsByAppIdAgentComposerResponse, zPutAppsByAppIdBody, zPutAppsByAppIdPath, zPutAppsByAppIdResponse, @@ -365,6 +387,9 @@ import { zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdPath, zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse, zPutAppsByAppIdWorkflowCommentsByCommentIdResponse, + zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerBody, + zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath, + zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse, zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetPath, zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse, } from './zod.gen' @@ -724,6 +749,104 @@ export const advancedChat = { workflows: workflows2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get4 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentComposerCandidates', + path: '/apps/{app_id}/agent-composer/candidates', + tags: ['console'], + }) + .input(z.object({ params: zGetAppsByAppIdAgentComposerCandidatesPath })) + .output(zGetAppsByAppIdAgentComposerCandidatesResponse) + +export const candidates = { + get: get4, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post9 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAgentComposerValidate', + path: '/apps/{app_id}/agent-composer/validate', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdAgentComposerValidateBody, + params: zPostAppsByAppIdAgentComposerValidatePath, + }), + ) + .output(zPostAppsByAppIdAgentComposerValidateResponse) + +export const validate = { + post: post9, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get5 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentComposer', + path: '/apps/{app_id}/agent-composer', + tags: ['console'], + }) + .input(z.object({ params: zGetAppsByAppIdAgentComposerPath })) + .output(zGetAppsByAppIdAgentComposerResponse) + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const put = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAppsByAppIdAgentComposer', + path: '/apps/{app_id}/agent-composer', + tags: ['console'], + }) + .input( + z.object({ body: zPutAppsByAppIdAgentComposerBody, params: zPutAppsByAppIdAgentComposerPath }), + ) + .output(zPutAppsByAppIdAgentComposerResponse) + +export const agentComposer = { + get: get5, + put, + candidates, + validate, +} + /** * Get agent logs * @@ -733,7 +856,7 @@ export const advancedChat = { * * @deprecated */ -export const get4 = oc +export const get6 = oc .route({ deprecated: true, description: @@ -749,7 +872,7 @@ export const get4 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get4, + get: get6, } export const agent = { @@ -763,7 +886,7 @@ export const agent = { * * @deprecated */ -export const get5 = oc +export const get7 = oc .route({ deprecated: true, description: @@ -778,7 +901,7 @@ export const get5 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get5, + get: get7, } export const status = { @@ -792,7 +915,7 @@ export const status = { * * @deprecated */ -export const post9 = oc +export const post10 = oc .route({ deprecated: true, description: @@ -812,7 +935,7 @@ export const post9 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post9, + post: post10, status, } @@ -827,7 +950,7 @@ export const annotationReply = { * * @deprecated */ -export const get6 = oc +export const get8 = oc .route({ deprecated: true, description: @@ -842,7 +965,7 @@ export const get6 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get6, + get: get8, } /** @@ -852,7 +975,7 @@ export const annotationSetting = { * * @deprecated */ -export const post10 = oc +export const post11 = oc .route({ deprecated: true, description: @@ -872,7 +995,7 @@ export const post10 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post10, + post: post11, } export const annotationSettings = { @@ -886,7 +1009,7 @@ export const annotationSettings = { * * @deprecated */ -export const post11 = oc +export const post12 = oc .route({ deprecated: true, description: @@ -901,7 +1024,7 @@ export const post11 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post11, + post: post12, } /** @@ -911,7 +1034,7 @@ export const batchImport = { * * @deprecated */ -export const get7 = oc +export const get9 = oc .route({ deprecated: true, description: @@ -926,7 +1049,7 @@ export const get7 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get7, + get: get9, } export const batchImportStatus = { @@ -936,7 +1059,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get8 = oc +export const get10 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -949,13 +1072,13 @@ export const get8 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get8, + get: get10, } /** * Export all annotations for an app with CSV injection protection */ -export const get9 = oc +export const get11 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -968,13 +1091,13 @@ export const get9 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get9, + get: get11, } /** * Get hit histories for an annotation */ -export const get10 = oc +export const get12 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -992,7 +1115,7 @@ export const get10 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get10, + get: get12, } /** @@ -1021,7 +1144,7 @@ export const delete_ = oc * * @deprecated */ -export const post12 = oc +export const post13 = oc .route({ deprecated: true, description: @@ -1042,7 +1165,7 @@ export const post12 = oc export const byAnnotationId = { delete: delete_, - post: post12, + post: post13, hitHistories, } @@ -1072,7 +1195,7 @@ export const delete2 = oc * * @deprecated */ -export const get11 = oc +export const get13 = oc .route({ deprecated: true, description: @@ -1098,7 +1221,7 @@ export const get11 = oc * * @deprecated */ -export const post13 = oc +export const post14 = oc .route({ deprecated: true, description: @@ -1117,8 +1240,8 @@ export const post13 = oc export const annotations = { delete: delete2, - get: get11, - post: post13, + get: get13, + post: post14, batchImport, batchImportStatus, count: count2, @@ -1133,7 +1256,7 @@ export const annotations = { * * @deprecated */ -export const post14 = oc +export const post15 = oc .route({ deprecated: true, description: @@ -1148,13 +1271,13 @@ export const post14 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post14, + post: post15, } /** * Transcript audio to text for chat messages */ -export const post15 = oc +export const post16 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1167,7 +1290,7 @@ export const post15 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post15, + post: post16, } /** @@ -1193,7 +1316,7 @@ export const delete3 = oc * * @deprecated */ -export const get12 = oc +export const get14 = oc .route({ deprecated: true, description: @@ -1209,13 +1332,13 @@ export const get12 = oc export const byConversationId = { delete: delete3, - get: get12, + get: get14, } /** * Get chat conversations with pagination, filtering and summary */ -export const get13 = oc +export const get15 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1233,14 +1356,14 @@ export const get13 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get13, + get: get15, byConversationId, } /** * Get suggested questions for a message */ -export const get14 = oc +export const get16 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1253,7 +1376,7 @@ export const get14 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get14, + get: get16, } export const byMessageId = { @@ -1263,7 +1386,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post16 = oc +export const post17 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1276,7 +1399,7 @@ export const post16 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post16, + post: post17, } export const byTaskId = { @@ -1290,7 +1413,7 @@ export const byTaskId = { * * @deprecated */ -export const get15 = oc +export const get17 = oc .route({ deprecated: true, description: @@ -1307,7 +1430,7 @@ export const get15 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get15, + get: get17, byMessageId, byTaskId, } @@ -1335,7 +1458,7 @@ export const delete4 = oc * * @deprecated */ -export const get16 = oc +export const get18 = oc .route({ deprecated: true, description: @@ -1351,13 +1474,13 @@ export const get16 = oc export const byConversationId2 = { delete: delete4, - get: get16, + get: get18, } /** * Get completion conversations with pagination and filtering */ -export const get17 = oc +export const get19 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1375,14 +1498,14 @@ export const get17 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get17, + get: get19, byConversationId: byConversationId2, } /** * Stop a running completion message generation */ -export const post17 = oc +export const post18 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -1395,7 +1518,7 @@ export const post17 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post17, + post: post18, } export const byTaskId2 = { @@ -1409,7 +1532,7 @@ export const byTaskId2 = { * * @deprecated */ -export const post18 = oc +export const post19 = oc .route({ deprecated: true, description: @@ -1429,14 +1552,14 @@ export const post18 = oc .output(zPostAppsByAppIdCompletionMessagesResponse) export const completionMessages = { - post: post18, + post: post19, byTaskId: byTaskId2, } /** * Get conversation variables for an application */ -export const get18 = oc +export const get20 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -1454,7 +1577,7 @@ export const get18 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get18, + get: get20, } /** @@ -1464,7 +1587,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post19 = oc +export const post20 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -1484,7 +1607,7 @@ export const post19 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post19, + post: post20, } /** @@ -1496,7 +1619,7 @@ export const convertToWorkflow = { * * @deprecated */ -export const post20 = oc +export const post21 = oc .route({ deprecated: true, description: @@ -1513,7 +1636,7 @@ export const post20 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post20, + post: post21, } /** @@ -1521,7 +1644,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get19 = oc +export const get21 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -1537,7 +1660,7 @@ export const get19 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get19, + get: get21, } /** @@ -1547,7 +1670,7 @@ export const export2 = { * * @deprecated */ -export const get20 = oc +export const get22 = oc .route({ deprecated: true, description: @@ -1567,13 +1690,13 @@ export const get20 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get20, + get: get22, } /** * Create or update message feedback (like/dislike) */ -export const post21 = oc +export const post22 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -1586,7 +1709,7 @@ export const post21 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post21, + post: post22, export: export3, } @@ -1597,7 +1720,7 @@ export const feedbacks = { * * @deprecated */ -export const post22 = oc +export const post23 = oc .route({ deprecated: true, description: @@ -1612,7 +1735,7 @@ export const post22 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post22, + post: post23, } /** @@ -1622,7 +1745,7 @@ export const icon = { * * @deprecated */ -export const get21 = oc +export const get23 = oc .route({ deprecated: true, description: @@ -1637,7 +1760,7 @@ export const get21 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get21, + get: get23, } export const messages = { @@ -1653,7 +1776,7 @@ export const messages = { * * @deprecated */ -export const post23 = oc +export const post24 = oc .route({ deprecated: true, description: @@ -1671,7 +1794,7 @@ export const post23 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post23, + post: post24, } /** @@ -1681,7 +1804,7 @@ export const modelConfig = { * * @deprecated */ -export const post24 = oc +export const post25 = oc .route({ deprecated: true, description: @@ -1696,13 +1819,13 @@ export const post24 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post24, + post: post25, } /** * Publish app to Creators Platform */ -export const post25 = oc +export const post26 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1715,7 +1838,7 @@ export const post25 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post25, + post: post26, } /** @@ -1725,7 +1848,7 @@ export const publishToCreatorsPlatform = { * * @deprecated */ -export const get22 = oc +export const get24 = oc .route({ deprecated: true, description: @@ -1746,7 +1869,7 @@ export const get22 = oc * * @deprecated */ -export const post26 = oc +export const post27 = oc .route({ deprecated: true, description: @@ -1768,7 +1891,7 @@ export const post26 = oc * * @deprecated */ -export const put = oc +export const put2 = oc .route({ deprecated: true, description: @@ -1783,15 +1906,15 @@ export const put = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get22, - post: post26, - put, + get: get24, + post: post27, + put: put2, } /** * Reset access token for application site */ -export const post27 = oc +export const post28 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -1804,13 +1927,13 @@ export const post27 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post27, + post: post28, } /** * Update application site configuration */ -export const post28 = oc +export const post29 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -1823,7 +1946,7 @@ export const post28 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post28, + post: post29, accessTokenReset, } @@ -1834,7 +1957,7 @@ export const site = { * * @deprecated */ -export const post29 = oc +export const post30 = oc .route({ deprecated: true, description: @@ -1849,7 +1972,7 @@ export const post29 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post29, + post: post30, } /** @@ -1859,7 +1982,7 @@ export const siteEnable = { * * @deprecated */ -export const get23 = oc +export const get25 = oc .route({ deprecated: true, description: @@ -1879,7 +2002,7 @@ export const get23 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get23, + get: get25, } /** @@ -1889,7 +2012,7 @@ export const averageResponseTime = { * * @deprecated */ -export const get24 = oc +export const get26 = oc .route({ deprecated: true, description: @@ -1909,7 +2032,7 @@ export const get24 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get24, + get: get26, } /** @@ -1919,7 +2042,7 @@ export const averageSessionInteractions = { * * @deprecated */ -export const get25 = oc +export const get27 = oc .route({ deprecated: true, description: @@ -1939,7 +2062,7 @@ export const get25 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get25, + get: get27, } /** @@ -1949,7 +2072,7 @@ export const dailyConversations = { * * @deprecated */ -export const get26 = oc +export const get28 = oc .route({ deprecated: true, description: @@ -1969,7 +2092,7 @@ export const get26 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get26, + get: get28, } /** @@ -1979,7 +2102,7 @@ export const dailyEndUsers = { * * @deprecated */ -export const get27 = oc +export const get29 = oc .route({ deprecated: true, description: @@ -1999,7 +2122,7 @@ export const get27 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get27, + get: get29, } /** @@ -2009,7 +2132,7 @@ export const dailyMessages = { * * @deprecated */ -export const get28 = oc +export const get30 = oc .route({ deprecated: true, description: @@ -2029,7 +2152,7 @@ export const get28 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get28, + get: get30, } /** @@ -2039,7 +2162,7 @@ export const tokenCosts = { * * @deprecated */ -export const get29 = oc +export const get31 = oc .route({ deprecated: true, description: @@ -2059,7 +2182,7 @@ export const get29 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get29, + get: get31, } /** @@ -2069,7 +2192,7 @@ export const tokensPerSecond = { * * @deprecated */ -export const get30 = oc +export const get32 = oc .route({ deprecated: true, description: @@ -2089,7 +2212,7 @@ export const get30 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get30, + get: get32, } export const statistics = { @@ -2110,7 +2233,7 @@ export const statistics = { * * @deprecated */ -export const get31 = oc +export const get33 = oc .route({ deprecated: true, description: @@ -2130,7 +2253,7 @@ export const get31 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get31, + get: get33, } /** @@ -2140,7 +2263,7 @@ export const voices = { * * @deprecated */ -export const post30 = oc +export const post31 = oc .route({ deprecated: true, description: @@ -2157,7 +2280,7 @@ export const post30 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post30, + post: post31, voices, } @@ -2170,7 +2293,7 @@ export const textToAudio = { * * @deprecated */ -export const get32 = oc +export const get34 = oc .route({ deprecated: true, description: @@ -2188,7 +2311,7 @@ export const get32 = oc /** * Update app tracing configuration */ -export const post31 = oc +export const post32 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2201,8 +2324,8 @@ export const post31 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get32, - post: post31, + get: get34, + post: post32, } /** @@ -2236,7 +2359,7 @@ export const delete5 = oc * * @deprecated */ -export const get33 = oc +export const get35 = oc .route({ deprecated: true, description: @@ -2287,7 +2410,7 @@ export const patch = oc * * @deprecated */ -export const post32 = oc +export const post33 = oc .route({ deprecated: true, description: @@ -2307,15 +2430,15 @@ export const post32 = oc export const traceConfig = { delete: delete5, - get: get33, + get: get35, patch, - post: post32, + post: post33, } /** * Update app trigger (enable/disable) */ -export const post33 = oc +export const post34 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2333,13 +2456,13 @@ export const post33 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post33, + post: post34, } /** * Get app triggers list */ -export const get34 = oc +export const get36 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2352,7 +2475,7 @@ export const get34 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get34, + get: get36, } /** @@ -2360,7 +2483,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get35 = oc +export const get37 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2379,7 +2502,7 @@ export const get35 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get35, + get: get37, } /** @@ -2387,7 +2510,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get36 = oc +export const get38 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2406,7 +2529,7 @@ export const get36 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get36, + get: get38, } /** @@ -2414,7 +2537,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get37 = oc +export const get39 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2433,7 +2556,7 @@ export const get37 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get37, + get: get39, } /** @@ -2441,7 +2564,7 @@ export const count3 = { * * Stop running workflow task */ -export const post34 = oc +export const post35 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2455,7 +2578,7 @@ export const post34 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post34, + post: post35, } export const byTaskId3 = { @@ -2469,7 +2592,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get38 = oc +export const get40 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2482,7 +2605,7 @@ export const get38 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get38, + get: get40, } /** @@ -2490,7 +2613,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get39 = oc +export const get41 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2504,7 +2627,7 @@ export const get39 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get39, + get: get41, } /** @@ -2512,7 +2635,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get40 = oc +export const get42 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2526,7 +2649,7 @@ export const get40 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get40, + get: get42, export: export4, nodeExecutions, } @@ -2536,7 +2659,7 @@ export const byRunId = { * * Get workflow run list */ -export const get41 = oc +export const get43 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2555,7 +2678,7 @@ export const get41 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get41, + get: get43, count: count3, tasks, byRunId, @@ -2566,7 +2689,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get42 = oc +export const get44 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2580,7 +2703,7 @@ export const get42 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get42, + get: get44, } /** @@ -2607,7 +2730,7 @@ export const delete6 = oc * * Update a comment reply */ -export const put2 = oc +export const put3 = oc .route({ description: 'Update a comment reply', inputStructure: 'detailed', @@ -2627,7 +2750,7 @@ export const put2 = oc export const byReplyId = { delete: delete6, - put: put2, + put: put3, } /** @@ -2635,7 +2758,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post35 = oc +export const post36 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -2655,7 +2778,7 @@ export const post35 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post35, + post: post36, byReplyId, } @@ -2664,7 +2787,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post36 = oc +export const post37 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -2678,7 +2801,7 @@ export const post36 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post36, + post: post37, } /** @@ -2705,7 +2828,7 @@ export const delete7 = oc * * Get a specific workflow comment */ -export const get43 = oc +export const get45 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -2723,7 +2846,7 @@ export const get43 = oc * * Update a workflow comment */ -export const put3 = oc +export const put4 = oc .route({ description: 'Update a workflow comment', inputStructure: 'detailed', @@ -2743,8 +2866,8 @@ export const put3 = oc export const byCommentId = { delete: delete7, - get: get43, - put: put3, + get: get45, + put: put4, replies, resolve, } @@ -2754,7 +2877,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get44 = oc +export const get46 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -2772,7 +2895,7 @@ export const get44 = oc * * Create a new workflow comment */ -export const post37 = oc +export const post38 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -2792,8 +2915,8 @@ export const post37 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get44, - post: post37, + get: get46, + post: post38, mentionUsers, byCommentId, } @@ -2805,7 +2928,7 @@ export const comments = { * * @deprecated */ -export const get45 = oc +export const get47 = oc .route({ deprecated: true, description: @@ -2825,7 +2948,7 @@ export const get45 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get45, + get: get47, } /** @@ -2835,7 +2958,7 @@ export const averageAppInteractions = { * * @deprecated */ -export const get46 = oc +export const get48 = oc .route({ deprecated: true, description: @@ -2855,7 +2978,7 @@ export const get46 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get46, + get: get48, } /** @@ -2865,7 +2988,7 @@ export const dailyConversations2 = { * * @deprecated */ -export const get47 = oc +export const get49 = oc .route({ deprecated: true, description: @@ -2885,7 +3008,7 @@ export const get47 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get47, + get: get49, } /** @@ -2895,7 +3018,7 @@ export const dailyTerminals = { * * @deprecated */ -export const get48 = oc +export const get50 = oc .route({ deprecated: true, description: @@ -2915,7 +3038,7 @@ export const get48 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get48, + get: get50, } export const statistics2 = { @@ -2939,7 +3062,7 @@ export const workflow = { * * @deprecated */ -export const get49 = oc +export const get51 = oc .route({ deprecated: true, description: @@ -2960,7 +3083,7 @@ export const get49 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get49, + get: get51, } /** @@ -2972,7 +3095,7 @@ export const byBlockType = { * * @deprecated */ -export const get50 = oc +export const get52 = oc .route({ deprecated: true, description: @@ -2988,7 +3111,7 @@ export const get50 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get50, + get: get52, byBlockType, } @@ -2999,7 +3122,7 @@ export const defaultWorkflowBlockConfigs = { * * @deprecated */ -export const get51 = oc +export const get53 = oc .route({ deprecated: true, description: @@ -3020,7 +3143,7 @@ export const get51 = oc * * @deprecated */ -export const post38 = oc +export const post39 = oc .route({ deprecated: true, description: @@ -3040,8 +3163,8 @@ export const post38 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get51, - post: post38, + get: get53, + post: post39, } /** @@ -3053,7 +3176,7 @@ export const conversationVariables2 = { * * @deprecated */ -export const get52 = oc +export const get54 = oc .route({ deprecated: true, description: @@ -3075,7 +3198,7 @@ export const get52 = oc * * @deprecated */ -export const post39 = oc +export const post40 = oc .route({ deprecated: true, description: @@ -3095,8 +3218,8 @@ export const post39 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get52, - post: post39, + get: get54, + post: post40, } /** @@ -3106,7 +3229,7 @@ export const environmentVariables = { * * @deprecated */ -export const post40 = oc +export const post41 = oc .route({ deprecated: true, description: @@ -3126,7 +3249,7 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post40, + post: post41, } /** @@ -3138,7 +3261,7 @@ export const features = { * * @deprecated */ -export const post41 = oc +export const post42 = oc .route({ deprecated: true, description: @@ -3159,7 +3282,7 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post41, + post: post42, } /** @@ -3171,7 +3294,7 @@ export const deliveryTest = { * * @deprecated */ -export const post42 = oc +export const post43 = oc .route({ deprecated: true, description: @@ -3192,7 +3315,7 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) export const preview2 = { - post: post42, + post: post43, } /** @@ -3204,7 +3327,7 @@ export const preview2 = { * * @deprecated */ -export const post43 = oc +export const post44 = oc .route({ deprecated: true, description: @@ -3225,7 +3348,7 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post43, + post: post44, } export const form2 = { @@ -3255,7 +3378,7 @@ export const humanInput2 = { * * @deprecated */ -export const post44 = oc +export const post45 = oc .route({ deprecated: true, description: @@ -3276,7 +3399,7 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post44, + post: post45, } export const byNodeId5 = { @@ -3300,7 +3423,7 @@ export const iteration2 = { * * @deprecated */ -export const post45 = oc +export const post46 = oc .route({ deprecated: true, description: @@ -3321,7 +3444,7 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post45, + post: post46, } export const byNodeId6 = { @@ -3336,10 +3459,166 @@ export const loop2 = { nodes: nodes6, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get55 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidates', + path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates', + tags: ['console'], + }) + .input( + z.object({ params: zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath }), + ) + .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) + +export const candidates2 = { + get: get55, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post47 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpact', + path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact', + tags: ['console'], + }) + .input(z.object({ params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath })) + .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) + +export const impact = { + post: post47, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post48 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRoster', + path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody, + params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterPath, + }), + ) + .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) + +export const saveToRoster = { + post: post48, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post49 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidate', + path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody, + params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePath, + }), + ) + .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) + +export const validate2 = { + post: post49, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get56 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposer', + path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer', + tags: ['console'], + }) + .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath })) + .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const put5 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposer', + path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer', + tags: ['console'], + }) + .input( + z.object({ + body: zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerBody, + params: zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath, + }), + ) + .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) + +export const agentComposer2 = { + get: get56, + put: put5, + candidates: candidates2, + impact, + saveToRoster, + validate: validate2, +} + /** * Get last run result for draft workflow node */ -export const get53 = oc +export const get57 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3352,7 +3631,7 @@ export const get53 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get53, + get: get57, } /** @@ -3364,7 +3643,7 @@ export const lastRun = { * * @deprecated */ -export const post46 = oc +export const post50 = oc .route({ deprecated: true, description: @@ -3385,7 +3664,7 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post46, + post: post50, } /** @@ -3397,7 +3676,7 @@ export const run8 = { * * @deprecated */ -export const post47 = oc +export const post51 = oc .route({ deprecated: true, description: @@ -3413,7 +3692,7 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post47, + post: post51, } export const trigger = { @@ -3443,7 +3722,7 @@ export const delete8 = oc * * @deprecated */ -export const get54 = oc +export const get58 = oc .route({ deprecated: true, description: @@ -3459,10 +3738,11 @@ export const get54 = oc export const variables = { delete: delete8, - get: get54, + get: get58, } export const byNodeId7 = { + agentComposer: agentComposer2, lastRun, run: run8, trigger, @@ -3482,7 +3762,7 @@ export const nodes7 = { * * @deprecated */ -export const post48 = oc +export const post52 = oc .route({ deprecated: true, description: @@ -3503,7 +3783,7 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post48, + post: post52, } /** @@ -3513,7 +3793,7 @@ export const run10 = { * * @deprecated */ -export const get55 = oc +export const get59 = oc .route({ deprecated: true, description: @@ -3528,7 +3808,7 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get55, + get: get59, } /** @@ -3540,7 +3820,7 @@ export const systemVariables = { * * @deprecated */ -export const post49 = oc +export const post53 = oc .route({ deprecated: true, description: @@ -3561,7 +3841,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post49, + post: post53, } /** @@ -3573,7 +3853,7 @@ export const run11 = { * * @deprecated */ -export const post50 = oc +export const post54 = oc .route({ deprecated: true, description: @@ -3594,7 +3874,7 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post50, + post: post54, } export const trigger2 = { @@ -3609,7 +3889,7 @@ export const trigger2 = { * * @deprecated */ -export const put4 = oc +export const put6 = oc .route({ deprecated: true, description: @@ -3624,7 +3904,7 @@ export const put4 = oc .output(zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse) export const reset = { - put: put4, + put: put6, } /** @@ -3650,7 +3930,7 @@ export const delete9 = oc * * @deprecated */ -export const get56 = oc +export const get60 = oc .route({ deprecated: true, description: @@ -3692,7 +3972,7 @@ export const patch2 = oc export const byVariableId = { delete: delete9, - get: get56, + get: get60, patch: patch2, reset, } @@ -3722,7 +4002,7 @@ export const delete10 = oc * * @deprecated */ -export const get57 = oc +export const get61 = oc .route({ deprecated: true, description: @@ -3744,7 +4024,7 @@ export const get57 = oc export const variables2 = { delete: delete10, - get: get57, + get: get61, byVariableId, } @@ -3757,7 +4037,7 @@ export const variables2 = { * * @deprecated */ -export const get58 = oc +export const get62 = oc .route({ deprecated: true, description: @@ -3781,7 +4061,7 @@ export const get58 = oc * * @deprecated */ -export const post51 = oc +export const post55 = oc .route({ deprecated: true, description: @@ -3802,8 +4082,8 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get58, - post: post51, + get: get62, + post: post55, conversationVariables: conversationVariables2, environmentVariables, features, @@ -3826,7 +4106,7 @@ export const draft2 = { * * @deprecated */ -export const get59 = oc +export const get63 = oc .route({ deprecated: true, description: @@ -3848,7 +4128,7 @@ export const get59 = oc * * @deprecated */ -export const post52 = oc +export const post56 = oc .route({ deprecated: true, description: @@ -3869,8 +4149,8 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get59, - post: post52, + get: get63, + post: post56, } /** @@ -3880,7 +4160,7 @@ export const publish = { * * @deprecated */ -export const get60 = oc +export const get64 = oc .route({ deprecated: true, description: @@ -3901,7 +4181,7 @@ export const get60 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get60, + get: get64, } export const triggers2 = { @@ -3915,7 +4195,7 @@ export const triggers2 = { * * @deprecated */ -export const post53 = oc +export const post57 = oc .route({ deprecated: true, description: @@ -3930,7 +4210,7 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post53, + post: post57, } /** @@ -3999,7 +4279,7 @@ export const byWorkflowId = { * * @deprecated */ -export const get61 = oc +export const get65 = oc .route({ deprecated: true, description: @@ -4020,7 +4300,7 @@ export const get61 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get61, + get: get65, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4056,7 +4336,7 @@ export const delete12 = oc * * @deprecated */ -export const get62 = oc +export const get66 = oc .route({ deprecated: true, description: @@ -4080,7 +4360,7 @@ export const get62 = oc * * @deprecated */ -export const put5 = oc +export const put7 = oc .route({ deprecated: true, description: @@ -4097,9 +4377,10 @@ export const put5 = oc export const byAppId2 = { delete: delete12, - get: get62, - put: put5, + get: get66, + put: put7, advancedChat, + agentComposer, agent, annotationReply, annotationSetting, @@ -4165,7 +4446,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get63 = oc +export const get67 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4183,7 +4464,7 @@ export const get63 = oc * * Create a new API key for an app */ -export const post54 = oc +export const post58 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4198,8 +4479,8 @@ export const post54 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get63, - post: post54, + get: get67, + post: post58, byApiKeyId, } @@ -4214,7 +4495,7 @@ export const byResourceId = { * * @deprecated */ -export const get64 = oc +export const get68 = oc .route({ deprecated: true, description: @@ -4229,7 +4510,7 @@ export const get64 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get64, + get: get68, } export const server2 = { @@ -4245,7 +4526,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get65 = oc +export const get69 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4267,7 +4548,7 @@ export const get65 = oc * * @deprecated */ -export const post55 = oc +export const post59 = oc .route({ deprecated: true, description: @@ -4284,8 +4565,8 @@ export const post55 = oc .output(zPostAppsResponse) export const apps = { - get: get65, - post: post55, + get: get69, + post: post59, imports, workflows, byAppId: byAppId2, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 69435e357edd76..55bf4e4722783c 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -167,6 +167,19 @@ export type AdvancedChatWorkflowRunPayload = { query?: string } +export type ComposerSavePayload = { + agent_soul?: AgentSoulConfig + binding?: ComposerBindingPayload + client_revision_id?: string | null + idempotency_key?: string | null + new_agent_name?: string | null + node_job?: WorkflowNodeJobConfig + save_strategy: ComposerSaveStrategy + soul_lock?: ComposerSoulLockPayload + variant: ComposerVariant + version_note?: string | null +} + export type AnnotationReplyPayload = { embedding_model_name: string embedding_provider_name: string @@ -948,6 +961,61 @@ export type AdvancedChatWorkflowRunForListResponse = { version?: string | null } +export type AgentSoulConfig = { + app_features?: { + [key: string]: unknown + } + app_variables?: Array + env?: AgentSoulEnvConfig + human?: AgentSoulHumanConfig + knowledge?: AgentSoulKnowledgeConfig + memory?: AgentSoulMemoryConfig + misc_legacy?: { + [key: string]: unknown + } + prompt?: AgentSoulPromptConfig + sandbox?: AgentSoulSandboxConfig + schema_version?: number + skills_files?: AgentSoulSkillsFilesConfig + tools?: AgentSoulToolsConfig +} + +export type ComposerBindingPayload = { + agent_id?: string | null + binding_type: 'inline_agent' | 'roster_agent' + current_snapshot_id?: string | null +} + +export type WorkflowNodeJobConfig = { + declared_outputs?: Array + human_contacts?: Array<{ + [key: string]: unknown + }> + metadata?: { + [key: string]: unknown + } + mode?: WorkflowNodeJobMode + previous_node_output_refs?: Array<{ + [key: string]: unknown + }> + schema_version?: number + workflow_prompt?: string +} + +export type ComposerSaveStrategy + = | 'node_job_only' + | 'save_as_new_agent' + | 'save_as_new_version' + | 'save_to_current_version' + | 'save_to_roster' + +export type ComposerSoulLockPayload = { + locked?: boolean + unlocked_from_version_id?: string | null +} + +export type ComposerVariant = 'agent_app' | 'workflow' + export type AnnotationHitHistory = { annotation_content?: string | null annotation_question?: string | null @@ -1284,6 +1352,91 @@ export type WorkflowOnlineUser = { username: string } +export type AppVariableConfig = { + default?: unknown + name: string + required?: boolean + type: string +} + +export type AgentSoulEnvConfig = { + secret_refs?: Array<{ + [key: string]: unknown + }> + variables?: Array<{ + [key: string]: unknown + }> +} + +export type AgentSoulHumanConfig = { + contacts?: Array<{ + [key: string]: unknown + }> + tools?: Array<{ + [key: string]: unknown + }> +} + +export type AgentSoulKnowledgeConfig = { + datasets?: Array<{ + [key: string]: unknown + }> + query_config?: { + [key: string]: unknown + } + query_mode?: AgentKnowledgeQueryMode +} + +export type AgentSoulMemoryConfig = { + artifacts?: Array<{ + [key: string]: unknown + }> + budget?: string | null + scope?: string | null +} + +export type AgentSoulPromptConfig = { + system_prompt?: string +} + +export type AgentSoulSandboxConfig = { + config?: { + [key: string]: unknown + } + provider?: string | null +} + +export type AgentSoulSkillsFilesConfig = { + files?: Array<{ + [key: string]: unknown + }> + skills?: Array<{ + [key: string]: unknown + }> +} + +export type AgentSoulToolsConfig = { + cli_tools?: Array<{ + [key: string]: unknown + }> + dify_tools?: Array<{ + [key: string]: unknown + }> +} + +export type DeclaredOutputConfig = { + checks?: Array + description?: string | null + failure_strategy?: DeclaredOutputFailureStrategy + file?: DeclaredOutputFileConfig + id?: string | null + name: string + required?: boolean + type: DeclaredOutputType +} + +export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do' + export type SimpleModelConfig = { model_dict?: JsonValue pre_prompt?: string | null @@ -1352,6 +1505,29 @@ export type WorkflowRunForArchivedLogResponse = { triggered_from?: string | null } +export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' + +export type DeclaredOutputCheckConfig = { + benchmark_file_ref?: { + [key: string]: unknown + } | null + prompt?: string | null + type: string +} + +export type DeclaredOutputFailureStrategy = { + max_retries?: number + on_output_check_failed?: string | null + on_type_check_failed?: string | null +} + +export type DeclaredOutputFileConfig = { + extensions?: Array + mime_types?: Array +} + +export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + export type UserActionConfig = { button_style?: ButtonStyle id: string @@ -1822,6 +1998,78 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses = { export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses] +export type GetAppsByAppIdAgentComposerData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/agent-composer' +} + +export type GetAppsByAppIdAgentComposerResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAppsByAppIdAgentComposerResponse + = GetAppsByAppIdAgentComposerResponses[keyof GetAppsByAppIdAgentComposerResponses] + +export type PutAppsByAppIdAgentComposerData = { + body: ComposerSavePayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/agent-composer' +} + +export type PutAppsByAppIdAgentComposerResponses = { + 200: { + [key: string]: unknown + } +} + +export type PutAppsByAppIdAgentComposerResponse + = PutAppsByAppIdAgentComposerResponses[keyof PutAppsByAppIdAgentComposerResponses] + +export type GetAppsByAppIdAgentComposerCandidatesData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/agent-composer/candidates' +} + +export type GetAppsByAppIdAgentComposerCandidatesResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAppsByAppIdAgentComposerCandidatesResponse + = GetAppsByAppIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdAgentComposerCandidatesResponses] + +export type PostAppsByAppIdAgentComposerValidateData = { + body: ComposerSavePayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/agent-composer/validate' +} + +export type PostAppsByAppIdAgentComposerValidateResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdAgentComposerValidateResponse + = PostAppsByAppIdAgentComposerValidateResponses[keyof PostAppsByAppIdAgentComposerValidateResponses] + export type GetAppsByAppIdAgentLogsData = { body?: never path: { @@ -4202,6 +4450,120 @@ export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponses = { export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse = PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponses] +export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = { + body?: never + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' +} + +export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse + = GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses] + +export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = { + body: ComposerSavePayload + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' +} + +export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = { + 200: { + [key: string]: unknown + } +} + +export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse + = PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses[keyof PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses] + +export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesData = { + body?: never + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates' +} + +export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse + = GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses] + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = { + body?: never + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact' +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse + = PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses] + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterData = { + body: ComposerSavePayload + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster' +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse + = PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses] + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateData = { + body: ComposerSavePayload + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate' +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse + = PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses] + export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 152296eec1c518..cd7175f3883f94 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -729,6 +729,39 @@ export const zSite = z.object({ use_icon_as_answer_icon: z.boolean().nullish(), }) +/** + * ComposerBindingPayload + */ +export const zComposerBindingPayload = z.object({ + agent_id: z.string().nullish(), + binding_type: z.enum(['inline_agent', 'roster_agent']), + current_snapshot_id: z.string().nullish(), +}) + +/** + * ComposerSaveStrategy + */ +export const zComposerSaveStrategy = z.enum([ + 'node_job_only', + 'save_as_new_agent', + 'save_as_new_version', + 'save_to_current_version', + 'save_to_roster', +]) + +/** + * ComposerSoulLockPayload + */ +export const zComposerSoulLockPayload = z.object({ + locked: z.boolean().optional().default(true), + unlocked_from_version_id: z.string().nullish(), +}) + +/** + * ComposerVariant + */ +export const zComposerVariant = z.enum(['agent_app', 'workflow']) + /** * AnnotationHitHistory */ @@ -1442,6 +1475,77 @@ export const zWorkflowOnlineUsersResponse = z.object({ data: z.array(zWorkflowOnlineUsersByApp), }) +/** + * AppVariableConfig + */ +export const zAppVariableConfig = z.object({ + default: z.unknown().optional(), + name: z.string().min(1).max(255), + required: z.boolean().optional().default(false), + type: z.string().min(1).max(64), +}) + +/** + * AgentSoulEnvConfig + */ +export const zAgentSoulEnvConfig = z.object({ + secret_refs: z.array(z.record(z.string(), z.unknown())).optional(), + variables: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +/** + * AgentSoulHumanConfig + */ +export const zAgentSoulHumanConfig = z.object({ + contacts: z.array(z.record(z.string(), z.unknown())).optional(), + tools: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +/** + * AgentSoulMemoryConfig + */ +export const zAgentSoulMemoryConfig = z.object({ + artifacts: z.array(z.record(z.string(), z.unknown())).optional(), + budget: z.string().nullish(), + scope: z.string().nullish(), +}) + +/** + * AgentSoulPromptConfig + */ +export const zAgentSoulPromptConfig = z.object({ + system_prompt: z.string().optional().default(''), +}) + +/** + * AgentSoulSandboxConfig + */ +export const zAgentSoulSandboxConfig = z.object({ + config: z.record(z.string(), z.unknown()).optional(), + provider: z.string().nullish(), +}) + +/** + * AgentSoulSkillsFilesConfig + */ +export const zAgentSoulSkillsFilesConfig = z.object({ + files: z.array(z.record(z.string(), z.unknown())).optional(), + skills: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +/** + * AgentSoulToolsConfig + */ +export const zAgentSoulToolsConfig = z.object({ + cli_tools: z.array(z.record(z.string(), z.unknown())).optional(), + dify_tools: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +/** + * WorkflowNodeJobMode + */ +export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do']) + /** * SimpleModelConfig */ @@ -1629,6 +1733,119 @@ export const zWorkflowArchivedLogPaginationResponse = z.object({ total: z.int(), }) +/** + * AgentKnowledgeQueryMode + */ +export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']) + +/** + * AgentSoulKnowledgeConfig + */ +export const zAgentSoulKnowledgeConfig = z.object({ + datasets: z.array(z.record(z.string(), z.unknown())).optional(), + query_config: z.record(z.string(), z.unknown()).optional(), + query_mode: zAgentKnowledgeQueryMode.optional(), +}) + +/** + * AgentSoulConfig + */ +export const zAgentSoulConfig = z.object({ + app_features: z.record(z.string(), z.unknown()).optional(), + app_variables: z.array(zAppVariableConfig).optional(), + env: zAgentSoulEnvConfig.optional(), + human: zAgentSoulHumanConfig.optional(), + knowledge: zAgentSoulKnowledgeConfig.optional(), + memory: zAgentSoulMemoryConfig.optional(), + misc_legacy: z.record(z.string(), z.unknown()).optional(), + prompt: zAgentSoulPromptConfig.optional(), + sandbox: zAgentSoulSandboxConfig.optional(), + schema_version: z.int().optional().default(1), + skills_files: zAgentSoulSkillsFilesConfig.optional(), + tools: zAgentSoulToolsConfig.optional(), +}) + +/** + * DeclaredOutputCheckConfig + */ +export const zDeclaredOutputCheckConfig = z.object({ + benchmark_file_ref: z.record(z.string(), z.unknown()).nullish(), + prompt: z.string().nullish(), + type: z.string().min(1).max(64), +}) + +/** + * DeclaredOutputFailureStrategy + */ +export const zDeclaredOutputFailureStrategy = z.object({ + max_retries: z.int().gte(0).lte(10).optional().default(0), + on_output_check_failed: z.string().nullish(), + on_type_check_failed: z.string().nullish(), +}) + +/** + * DeclaredOutputFileConfig + */ +export const zDeclaredOutputFileConfig = z.object({ + extensions: z.array(z.string()).optional(), + mime_types: z.array(z.string()).optional(), +}) + +/** + * DeclaredOutputType + */ +export const zDeclaredOutputType = z.enum([ + 'array', + 'boolean', + 'file', + 'number', + 'object', + 'string', +]) + +/** + * DeclaredOutputConfig + */ +export const zDeclaredOutputConfig = z.object({ + checks: z.array(zDeclaredOutputCheckConfig).optional(), + description: z.string().nullish(), + failure_strategy: zDeclaredOutputFailureStrategy.optional(), + file: zDeclaredOutputFileConfig.optional(), + id: z.string().nullish(), + name: z.string().min(1).max(255), + required: z.boolean().optional().default(true), + type: zDeclaredOutputType, +}) + +/** + * WorkflowNodeJobConfig + */ +export const zWorkflowNodeJobConfig = z.object({ + declared_outputs: z.array(zDeclaredOutputConfig).optional(), + human_contacts: z.array(z.record(z.string(), z.unknown())).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + mode: zWorkflowNodeJobMode.optional(), + previous_node_output_refs: z.array(z.record(z.string(), z.unknown())).optional(), + schema_version: z.int().optional().default(1), + workflow_prompt: z.string().optional().default(''), +}) + +/** + * ComposerSavePayload + */ +export const zComposerSavePayload = z.object({ + agent_soul: zAgentSoulConfig.optional(), + binding: zComposerBindingPayload.optional(), + client_revision_id: z.string().nullish(), + idempotency_key: z.string().nullish(), + new_agent_name: z.string().min(1).max(255).nullish(), + node_job: zWorkflowNodeJobConfig.optional(), + save_strategy: zComposerSaveStrategy, + soul_lock: zComposerSoulLockPayload.optional(), + variant: zComposerVariant, + version_note: z.string().nullish(), +}) + export const zFormInputConfig = z.unknown() /** @@ -2071,6 +2288,46 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = z.record( z.unknown(), ) +export const zGetAppsByAppIdAgentComposerPath = z.object({ + app_id: z.string(), +}) + +/** + * Success + */ +export const zGetAppsByAppIdAgentComposerResponse = z.record(z.string(), z.unknown()) + +export const zPutAppsByAppIdAgentComposerBody = zComposerSavePayload + +export const zPutAppsByAppIdAgentComposerPath = z.object({ + app_id: z.string(), +}) + +/** + * Success + */ +export const zPutAppsByAppIdAgentComposerResponse = z.record(z.string(), z.unknown()) + +export const zGetAppsByAppIdAgentComposerCandidatesPath = z.object({ + app_id: z.string(), +}) + +/** + * Success + */ +export const zGetAppsByAppIdAgentComposerCandidatesResponse = z.record(z.string(), z.unknown()) + +export const zPostAppsByAppIdAgentComposerValidateBody = zComposerSavePayload + +export const zPostAppsByAppIdAgentComposerValidatePath = z.object({ + app_id: z.string(), +}) + +/** + * Success + */ +export const zPostAppsByAppIdAgentComposerValidateResponse = z.record(z.string(), z.unknown()) + export const zGetAppsByAppIdAgentLogsPath = z.object({ app_id: z.string(), }) @@ -3341,6 +3598,90 @@ export const zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse = z.reco z.unknown(), ) +export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.object({ + app_id: z.string(), + node_id: z.string(), +}) + +/** + * Success + */ +export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = z.record( + z.string(), + z.unknown(), +) + +export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerBody = zComposerSavePayload + +export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.object({ + app_id: z.string(), + node_id: z.string(), +}) + +/** + * Success + */ +export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = z.record( + z.string(), + z.unknown(), +) + +export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath = z.object({ + app_id: z.string(), + node_id: z.string(), +}) + +/** + * Success + */ +export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = z.record( + z.string(), + z.unknown(), +) + +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath = z.object({ + app_id: z.string(), + node_id: z.string(), +}) + +/** + * Success + */ +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse = z.record( + z.string(), + z.unknown(), +) + +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody + = zComposerSavePayload + +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterPath = z.object({ + app_id: z.string(), + node_id: z.string(), +}) + +/** + * Success + */ +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse + = z.record(z.string(), z.unknown()) + +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody + = zComposerSavePayload + +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePath = z.object({ + app_id: z.string(), + node_id: z.string(), +}) + +/** + * Success + */ +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse = z.record( + z.string(), + z.unknown(), +) + export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ app_id: z.string(), node_id: z.string(), diff --git a/packages/contracts/generated/api/console/orpc.gen.ts b/packages/contracts/generated/api/console/orpc.gen.ts index 06a4d7956312df..48f6d4df3bf96b 100644 --- a/packages/contracts/generated/api/console/orpc.gen.ts +++ b/packages/contracts/generated/api/console/orpc.gen.ts @@ -2,6 +2,7 @@ import { account } from './account/orpc.gen' import { activate } from './activate/orpc.gen' +import { agents } from './agents/orpc.gen' import { allWorkspaces } from './all-workspaces/orpc.gen' import { apiBasedExtension } from './api-based-extension/orpc.gen' import { apiKeyAuth } from './api-key-auth/orpc.gen' @@ -49,6 +50,7 @@ import { workspaces } from './workspaces/orpc.gen' export const contract = { account, activate, + agents, allWorkspaces, apiBasedExtension, apiKeyAuth, diff --git a/packages/contracts/generated/api/readiness.json b/packages/contracts/generated/api/readiness.json index d0c14d3df2f3c2..0497f9a7ba8090 100644 --- a/packages/contracts/generated/api/readiness.json +++ b/packages/contracts/generated/api/readiness.json @@ -1,8 +1,8 @@ { "surfaces": { "console": { - "notReady": 365, - "total": 570 + "notReady": 383, + "total": 588 }, "service": { "notReady": 60,