mirror of
https://github.com/langgenius/dify.git
synced 2026-05-25 10:00:43 -04:00
feat: add new agent (#36284)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
1
api/clients/__init__.py
Normal file
1
api/clients/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""External service client packages."""
|
||||
74
api/clients/agent_backend/__init__.py
Normal file
74
api/clients/agent_backend/__init__.py
Normal 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",
|
||||
]
|
||||
130
api/clients/agent_backend/client.py
Normal file
130
api/clients/agent_backend/client.py
Normal 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__)
|
||||
61
api/clients/agent_backend/errors.py
Normal file
61
api/clients/agent_backend/errors.py
Normal 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}")
|
||||
167
api/clients/agent_backend/event_adapter.py
Normal file
167
api/clients/agent_backend/event_adapter.py
Normal 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__}")
|
||||
22
api/clients/agent_backend/factory.py
Normal file
22
api/clients/agent_backend/factory.py
Normal 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))
|
||||
117
api/clients/agent_backend/fake_client.py
Normal file
117
api/clients/agent_backend/fake_client.py
Normal 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"),
|
||||
),
|
||||
)
|
||||
192
api/clients/agent_backend/request_builder.py
Normal file
192
api/clients/agent_backend/request_builder.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
3
api/controllers/console/agent/__init__.py
Normal file
3
api/controllers/console/agent/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import composer, roster
|
||||
|
||||
__all__ = ["composer", "roster"]
|
||||
153
api/controllers/console/agent/composer.py
Normal file
153
api/controllers/console/agent/composer.py
Normal 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)
|
||||
130
api/controllers/console/agent/roster.py
Normal file
130
api/controllers/console/agent/roster.py
Normal 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),
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
263
api/models/agent.py
Normal 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)
|
||||
136
api/models/agent_config_entities.py
Normal file
136
api/models/agent_config_entities.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
4
api/services/agent/__init__.py
Normal file
4
api/services/agent/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .composer_service import AgentComposerService
|
||||
from .roster_service import AgentRosterService
|
||||
|
||||
__all__ = ["AgentComposerService", "AgentRosterService"]
|
||||
767
api/services/agent/composer_service.py
Normal file
767
api/services/agent/composer_service.py
Normal 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
|
||||
71
api/services/agent/composer_validator.py
Normal file
71
api/services/agent/composer_validator.py
Normal 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}]")
|
||||
29
api/services/agent/errors.py
Normal file
29
api/services/agent/errors.py
Normal 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."
|
||||
320
api/services/agent/roster_service.py
Normal file
320
api/services/agent/roster_service.py
Normal 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}
|
||||
93
api/services/entities/agent_entities.py
Normal file
93
api/services/entities/agent_entities.py
Normal 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)
|
||||
1
api/tests/unit_tests/clients/__init__.py
Normal file
1
api/tests/unit_tests/clients/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Client unit tests."""
|
||||
1
api/tests/unit_tests/clients/agent_backend/__init__.py
Normal file
1
api/tests/unit_tests/clients/agent_backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Agent backend client contract tests."""
|
||||
126
api/tests/unit_tests/clients/agent_backend/test_client.py
Normal file
126
api/tests/unit_tests/clients/agent_backend/test_client.py
Normal 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"
|
||||
132
api/tests/unit_tests/clients/agent_backend/test_event_adapter.py
Normal file
132
api/tests/unit_tests/clients/agent_backend/test_event_adapter.py
Normal 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",
|
||||
)
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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]"
|
||||
@@ -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": []}
|
||||
192
api/tests/unit_tests/models/test_agent.py
Normal file
192
api/tests/unit_tests/models/test_agent.py
Normal 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"
|
||||
65
api/tests/unit_tests/models/test_types.py
Normal file
65
api/tests/unit_tests/models/test_types.py
Normal 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"))
|
||||
@@ -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"] == []
|
||||
575
api/tests/unit_tests/services/agent/test_agent_services.py
Normal file
575
api/tests/unit_tests/services/agent/test_agent_services.py
Normal 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
4
api/uv.lock
generated
@@ -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" },
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
203
packages/contracts/generated/api/console/agents/orpc.gen.ts
Normal file
203
packages/contracts/generated/api/console/agents/orpc.gen.ts
Normal 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,
|
||||
}
|
||||
255
packages/contracts/generated/api/console/agents/types.gen.ts
Normal file
255
packages/contracts/generated/api/console/agents/types.gen.ts
Normal 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]
|
||||
197
packages/contracts/generated/api/console/agents/zod.gen.ts
Normal file
197
packages/contracts/generated/api/console/agents/zod.gen.ts
Normal 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
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"surfaces": {
|
||||
"console": {
|
||||
"notReady": 365,
|
||||
"total": 570
|
||||
"notReady": 383,
|
||||
"total": 588
|
||||
},
|
||||
"service": {
|
||||
"notReady": 60,
|
||||
|
||||
Reference in New Issue
Block a user