feat: add new agent (#36284)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123
2026-05-19 18:43:23 +08:00
committed by GitHub
parent d1417bbe4b
commit d9e90d0fa0
54 changed files with 7377 additions and 242 deletions

1
api/clients/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""External service client packages."""

View File

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

View File

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

View File

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

View File

@@ -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__}")

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,8 @@ from . import (
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 @@ __all__ = [
"activate",
"advanced_prompt_template",
"agent",
"agent_composer",
"agent_providers",
"agent_roster",
"annotation",
"api",
"apikey",

View File

@@ -0,0 +1,3 @@
from . import composer, roster
__all__ = ["composer", "roster"]

View File

@@ -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/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:app_id>/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/<uuid:app_id>/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/<uuid:app_id>/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)

View File

@@ -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/<uuid:agent_id>")
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/<uuid:agent_id>/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/<uuid:agent_id>/versions/<uuid:version_id>")
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),
)

View File

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

View File

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

View File

@@ -8,6 +8,19 @@ from .account import (
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 @@ __all__ = [
"AccountIntegrate",
"AccountStatus",
"AccountTrialAppRecord",
"Agent",
"AgentConfigRevision",
"AgentConfigRevisionOperation",
"AgentConfigSnapshot",
"AgentIconType",
"AgentKind",
"AgentScope",
"AgentSource",
"AgentStatus",
"ApiRequest",
"ApiToken",
"ApiToolProvider",
@@ -210,6 +232,8 @@ __all__ = [
"UploadFile",
"Whitelist",
"Workflow",
"WorkflowAgentBindingType",
"WorkflowAgentNodeBinding",
"WorkflowAppLog",
"WorkflowAppLogCreatedFrom",
"WorkflowArchiveLog",

263
api/models/agent.py Normal file
View File

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

View File

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

View File

@@ -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 @@ class LongText(TypeDecorator[str | None]):
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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
from .composer_service import AgentComposerService
from .roster_service import AgentRosterService
__all__ = ["AgentComposerService", "AgentRosterService"]

View File

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

View File

@@ -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}]")

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
"""Client unit tests."""

View File

@@ -0,0 +1 @@
"""Agent backend client contract tests."""

View File

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

View File

@@ -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",
)
]

View File

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

View File

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

View File

@@ -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": []}

View File

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

View File

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

View File

@@ -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"] == []

View File

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

4
api/uv.lock generated
View File

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

View File

@@ -23,6 +23,8 @@ import httpx
from pydantic import BaseModel, ValidationError
from dify_agent.protocol.schemas import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RUN_EVENT_ADAPTER,
@@ -32,8 +34,8 @@ from dify_agent.protocol.schemas import (
)
_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 @@ class Client:
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:

View File

@@ -5,17 +5,26 @@ from .schemas import (
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 @@ from .schemas import (
__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",

View File

@@ -43,12 +43,16 @@ from agenton.layers import ExitIntent
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",

View File

@@ -13,7 +13,14 @@ from typing import Annotated
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 @@ def create_runs_router(
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,

View File

@@ -20,8 +20,11 @@ from dify_agent.client import (
DifyAgentValidationError,
)
from dify_agent.protocol.schemas import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
RUN_EVENT_ADAPTER,
RunCancelledEvent,
RunEvent,
RunEventsResponse,
RunStartedEvent,
@@ -97,6 +100,10 @@ def test_sync_methods_parse_protocol_dtos_and_send_create_request_dto() -> None:
"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 test_sync_methods_parse_protocol_dtos_and_send_create_request_dto() -> None:
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 test_stream_events_stops_after_terminal_event() -> None:
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] = []

View File

@@ -12,12 +12,17 @@ from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAY
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"})

View File

@@ -67,6 +67,21 @@ def test_create_run_returns_running_from_scheduler() -> None:
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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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<AppVariableConfig>
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<DeclaredOutputConfig>
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<DeclaredOutputCheckConfig>
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<string>
mime_types?: Array<string>
}
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: {

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
{
"surfaces": {
"console": {
"notReady": 365,
"total": 570
"notReady": 383,
"total": 588
},
"service": {
"notReady": 60,