mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:23:44 +08:00
feat(api): Agent App type S1 — AppMode.AGENT + create flow + binding (#36829)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
e530e84772
commit
e35d23c3cb
@ -34,6 +34,7 @@ from clients.agent_backend.request_builder import (
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID,
|
||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
AgentBackendAgentAppRunInput,
|
||||
AgentBackendModelConfig,
|
||||
AgentBackendOutputConfig,
|
||||
AgentBackendRunRequestBuilder,
|
||||
@ -49,6 +50,7 @@ __all__ = [
|
||||
"DIFY_PLUGIN_TOOLS_LAYER_ID",
|
||||
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
|
||||
"WORKFLOW_USER_PROMPT_LAYER_ID",
|
||||
"AgentBackendAgentAppRunInput",
|
||||
"AgentBackendError",
|
||||
"AgentBackendHTTPError",
|
||||
"AgentBackendInternalEvent",
|
||||
|
||||
@ -45,6 +45,7 @@ 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"
|
||||
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
|
||||
@ -181,9 +182,138 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class AgentBackendAgentAppRunInput(BaseModel):
|
||||
"""Inputs to build one Agent App conversation-turn run request.
|
||||
|
||||
Unlike the workflow-node input there is no workflow-node-job prompt and no
|
||||
previous-node context: the user prompt is the chat message, and multi-turn
|
||||
continuity comes from ``session_snapshot`` + the history layer keyed by the
|
||||
conversation.
|
||||
"""
|
||||
|
||||
model: AgentBackendModelConfig
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
user_prompt: str
|
||||
agent_soul_prompt: str | None = None
|
||||
purpose: RunPurpose = "agent_app"
|
||||
idempotency_key: str | None = None
|
||||
output: AgentBackendOutputConfig | None = None
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
include_history: bool = True
|
||||
suspend_on_exit: bool = True
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
@field_validator("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_agent_app(self, run_input: AgentBackendAgentAppRunInput) -> CreateRunRequest:
|
||||
"""Build an Agent App conversation-turn run request.
|
||||
|
||||
Layer graph: optional Agent Soul system prompt → user prompt →
|
||||
execution context → optional history (multi-turn) → LLM → optional
|
||||
plugin tools → optional structured output. Mirrors the workflow-node
|
||||
layer ordering minus the workflow-job / previous-node prompt.
|
||||
"""
|
||||
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=AGENT_APP_USER_PROMPT_LAYER_ID,
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "agent_app_user_prompt"},
|
||||
config=PromptLayerConfig(user=run_input.user_prompt),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.execution_context,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_HISTORY_LAYER_ID,
|
||||
type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "agent_session_history"},
|
||||
)
|
||||
)
|
||||
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id=run_input.model.plugin_id,
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
model_settings=run_input.model.model_settings or None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.tools is not None and run_input.tools.tools:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
|
||||
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.tools,
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
description=run_input.output.description,
|
||||
strict=run_input.output.strict,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return CreateRunRequest(
|
||||
composition=RunComposition(layers=layers),
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
def build_cleanup_request(
|
||||
self,
|
||||
*,
|
||||
|
||||
@ -81,4 +81,15 @@ default_app_templates: Mapping[AppMode, Mapping] = {
|
||||
},
|
||||
},
|
||||
},
|
||||
# agent default mode (new Agent App type). The runtime model / prompt / tools
|
||||
# come from the bound Agent Soul snapshot, so no model_config is seeded in the
|
||||
# template; create_app still creates a model-less app_model_config row to hold
|
||||
# app-level presentation features (opener, follow-up, citations, ...).
|
||||
AppMode.AGENT: {
|
||||
"app": {
|
||||
"mode": AppMode.AGENT,
|
||||
"enable_site": True,
|
||||
"enable_api": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -51,6 +51,8 @@ from .agent import roster as agent_roster
|
||||
from .app import (
|
||||
advanced_prompt_template,
|
||||
agent,
|
||||
agent_app_access,
|
||||
agent_app_feature,
|
||||
annotation,
|
||||
app,
|
||||
audio,
|
||||
@ -146,6 +148,8 @@ __all__ = [
|
||||
"activate",
|
||||
"advanced_prompt_template",
|
||||
"agent",
|
||||
"agent_app_access",
|
||||
"agent_app_feature",
|
||||
"agent_composer",
|
||||
"agent_providers",
|
||||
"agent_roster",
|
||||
|
||||
59
api/controllers/console/app/agent_app_access.py
Normal file
59
api/controllers/console/app/agent_app_access.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Agent App access & sharing endpoints (read-only workflow references).
|
||||
|
||||
An Agent App is backed by a roster Agent that workflow Agent nodes may also
|
||||
reference. This exposes the read-only "Workflow access" surface from the PRD:
|
||||
which workflow apps use this Agent, without leaking the workflows' internals.
|
||||
"""
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import Field
|
||||
|
||||
from controllers.common.schema import register_response_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, setup_required
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
|
||||
|
||||
class AgentReferencingWorkflowResponse(ResponseModel):
|
||||
app_id: str
|
||||
app_name: str
|
||||
app_mode: str
|
||||
workflow_id: str
|
||||
node_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentReferencingWorkflowsResponse(ResponseModel):
|
||||
data: list[AgentReferencingWorkflowResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_response_schema_models(console_ns, AgentReferencingWorkflowsResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-referencing-workflows")
|
||||
class AgentAppReferencingWorkflowsResource(Resource):
|
||||
@console_ns.doc("list_agent_app_referencing_workflows")
|
||||
@console_ns.doc(description="List workflow apps that reference this Agent App's bound Agent (read-only)")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Referencing workflows listed successfully",
|
||||
console_ns.models[AgentReferencingWorkflowsResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "App not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
def get(self, app_model: App):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
workflows = AgentRosterService(db.session).list_workflows_referencing_app_agent(
|
||||
tenant_id=tenant_id, app_id=app_model.id
|
||||
)
|
||||
return AgentReferencingWorkflowsResponse(
|
||||
data=[AgentReferencingWorkflowResponse.model_validate(workflow) for workflow in workflows]
|
||||
).model_dump(mode="json")
|
||||
80
api/controllers/console/app/agent_app_feature.py
Normal file
80
api/controllers/console/app/agent_app_feature.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""Agent App presentation-feature configuration endpoint.
|
||||
|
||||
The new Agent App type keeps model / prompt / tools in its bound Agent Soul, so
|
||||
the legacy ``/model-config`` surface (which writes model, prompt and agent tool
|
||||
config) is the wrong place to configure its app-level presentation features.
|
||||
This endpoint exposes only the PRD "Misc Legacy" feature subset — conversation
|
||||
opener, follow-up suggestions, citations, content moderation and speech — and
|
||||
persists them onto the app's ``app_model_config`` without touching anything the
|
||||
Soul owns.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, 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 events.app_event import app_model_config_was_updated
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent_app_feature_service import AgentAppFeatureConfigService
|
||||
|
||||
|
||||
class AgentAppFeaturesRequest(BaseModel):
|
||||
"""Presentation features configurable on an Agent App.
|
||||
|
||||
All fields are optional; an omitted field is reset to its disabled/empty
|
||||
default (the config form sends the full desired feature state on save).
|
||||
"""
|
||||
|
||||
opening_statement: str | None = Field(default=None, description="Conversation opener shown before the first turn")
|
||||
suggested_questions: list[str] | None = Field(
|
||||
default=None, description="Preset questions shown alongside the opener"
|
||||
)
|
||||
suggested_questions_after_answer: dict[str, Any] | None = Field(
|
||||
default=None, description="Follow-up suggestions config, e.g. {'enabled': true}"
|
||||
)
|
||||
speech_to_text: dict[str, Any] | None = Field(default=None, description="Speech-to-text config")
|
||||
text_to_speech: dict[str, Any] | None = Field(default=None, description="Text-to-speech config")
|
||||
retriever_resource: dict[str, Any] | None = Field(
|
||||
default=None, description="Citations / attributions config, e.g. {'enabled': true}"
|
||||
)
|
||||
sensitive_word_avoidance: dict[str, Any] | None = Field(default=None, description="Content moderation config")
|
||||
|
||||
|
||||
register_schema_models(console_ns, AgentAppFeaturesRequest)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-features")
|
||||
class AgentAppFeatureConfigResource(Resource):
|
||||
@console_ns.doc("update_agent_app_features")
|
||||
@console_ns.doc(description="Update an Agent App's presentation features (opener, follow-up, citations, ...)")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[AgentAppFeaturesRequest.__name__])
|
||||
@console_ns.response(200, "Features updated successfully", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(400, "Invalid configuration")
|
||||
@console_ns.response(404, "App not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@edit_permission_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
def post(self, app_model: App):
|
||||
args = AgentAppFeaturesRequest.model_validate(console_ns.payload)
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
new_app_model_config = AgentAppFeatureConfigService.update_features(
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
config=args.model_dump(exclude_none=True),
|
||||
)
|
||||
|
||||
app_model_config_was_updated.send(app_model, app_model_config=new_app_model_config)
|
||||
|
||||
return {"result": "success"}
|
||||
@ -55,7 +55,7 @@ from services.entities.knowledge_entities.knowledge_entities import (
|
||||
)
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
|
||||
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
|
||||
|
||||
register_enum_models(console_ns, IconType)
|
||||
|
||||
@ -66,7 +66,7 @@ _TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
|
||||
class AppListQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)")
|
||||
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "channel", "all"] = Field(
|
||||
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = Field(
|
||||
default="all", description="App mode filter"
|
||||
)
|
||||
name: str | None = Field(default=None, description="Filter by app name")
|
||||
@ -115,7 +115,9 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str,
|
||||
class CreateAppPayload(BaseModel):
|
||||
name: str = Field(..., min_length=1, description="App name")
|
||||
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
|
||||
mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = Field(..., description="App mode")
|
||||
mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] = Field(
|
||||
..., description="App mode"
|
||||
)
|
||||
icon_type: IconType | None = Field(default=None, description="Icon type")
|
||||
icon: str | None = Field(default=None, description="Icon")
|
||||
icon_background: str | None = Field(default=None, description="Icon background color")
|
||||
@ -393,6 +395,8 @@ class AppDetailWithSite(AppDetail):
|
||||
max_active_requests: int | None = None
|
||||
deleted_tools: list[DeletedTool] = Field(default_factory=list)
|
||||
site: Site | None = None
|
||||
# For Agent App type: the roster Agent backing this app (None otherwise).
|
||||
bound_agent_id: str | None = None
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore
|
||||
@property
|
||||
|
||||
@ -4,7 +4,7 @@ from typing import Any, Literal
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
@ -41,9 +41,24 @@ from services.errors.llm import InvokeRateLimitError
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_debugger_chat_streaming(
|
||||
*, app_mode: AppMode, response_mode: str, response_mode_provided: bool = True
|
||||
) -> bool:
|
||||
"""Agent App runtime is SSE-only until backend blocking runs are supported."""
|
||||
if app_mode != AppMode.AGENT:
|
||||
return response_mode != "blocking"
|
||||
if response_mode_provided and response_mode == "blocking":
|
||||
raise BadRequest("Agent App only supports streaming response mode.")
|
||||
return True
|
||||
|
||||
|
||||
class BaseMessagePayload(BaseModel):
|
||||
inputs: dict[str, Any]
|
||||
model_config_data: dict[str, Any] = Field(..., alias="model_config")
|
||||
# Agent Apps (AppMode.AGENT) derive their model + prompt from the bound Agent
|
||||
# Soul, so no override ``model_config`` is sent; chat / agent-chat / completion
|
||||
# debugging still pass it. Optional here, required in practice by those modes
|
||||
# downstream when their config is built from args.
|
||||
model_config_data: dict[str, Any] = Field(default_factory=dict, alias="model_config")
|
||||
files: list[Any] | None = Field(default=None, description="Uploaded files")
|
||||
response_mode: Literal["blocking", "streaming"] = Field(default="blocking", description="Response mode")
|
||||
retriever_from: str = Field(default="dev", description="Retriever source")
|
||||
@ -157,13 +172,20 @@ class ChatMessageApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT])
|
||||
@edit_permission_required
|
||||
def post(self, app_model: App):
|
||||
args_model = ChatMessagePayload.model_validate(console_ns.payload)
|
||||
raw_payload = console_ns.payload or {}
|
||||
args_model = ChatMessagePayload.model_validate(raw_payload)
|
||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||
|
||||
streaming = args_model.response_mode != "blocking"
|
||||
streaming = _resolve_debugger_chat_streaming(
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
response_mode=args_model.response_mode,
|
||||
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
|
||||
)
|
||||
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
|
||||
args["response_mode"] = "streaming"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
@ -211,7 +233,7 @@ class ChatMessageStopApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def post(self, app_model: App, task_id: str):
|
||||
if not isinstance(current_user, Account):
|
||||
raise ValueError("current_user must be an Account instance")
|
||||
|
||||
@ -212,7 +212,7 @@ class ChatConversationApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, app_model: App):
|
||||
@ -323,7 +323,7 @@ class ChatConversationDetailApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, app_model: App, conversation_id: UUID):
|
||||
@ -340,7 +340,7 @@ class ChatConversationDetailApi(Resource):
|
||||
@console_ns.response(404, "Conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
|
||||
@ -180,7 +180,7 @@ class ChatMessageListApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@setup_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App):
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||
@ -337,7 +337,7 @@ class MessageSuggestedQuestionApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, app_model: App, message_id: UUID):
|
||||
message_id_str = str(message_id)
|
||||
|
||||
@ -290,7 +290,7 @@ class AverageSessionInteractionStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@with_current_user
|
||||
def get(self, account: Account, app_model: App):
|
||||
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
@ -41,6 +41,15 @@ from services.errors.llm import InvokeRateLimitError
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_agent_app_streaming(*, app_mode: AppMode, response_mode: str | None) -> bool:
|
||||
"""Agent App runtime is SSE-only until backend blocking runs are supported."""
|
||||
if app_mode != AppMode.AGENT:
|
||||
return response_mode == "streaming"
|
||||
if response_mode == "blocking":
|
||||
raise BadRequest("Agent App only supports streaming response mode.")
|
||||
return True
|
||||
|
||||
|
||||
class CompletionRequestPayload(BaseModel):
|
||||
inputs: dict[str, Any]
|
||||
query: str = Field(default="")
|
||||
@ -197,7 +206,7 @@ class ChatApi(Resource):
|
||||
Supports conversation management and both blocking and streaming response modes.
|
||||
"""
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
payload = ChatRequestPayload.model_validate(service_api_ns.payload or {})
|
||||
@ -207,7 +216,7 @@ class ChatApi(Resource):
|
||||
if external_trace_id:
|
||||
args["external_trace_id"] = external_trace_id
|
||||
|
||||
streaming = payload.response_mode == "streaming"
|
||||
streaming = _resolve_agent_app_streaming(app_mode=app_mode, response_mode=payload.response_mode)
|
||||
|
||||
try:
|
||||
response = AppGenerateService.generate(
|
||||
@ -262,7 +271,7 @@ class ChatStopApi(Resource):
|
||||
def post(self, app_model: App, end_user: EndUser, task_id: str):
|
||||
"""Stop a running chat message generation."""
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
AppTaskService.stop_task(
|
||||
|
||||
@ -155,7 +155,7 @@ class ConversationApi(Resource):
|
||||
Supports pagination using last_id and limit parameters.
|
||||
"""
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
query_args = ConversationListQuery.model_validate(request.args.to_dict())
|
||||
@ -199,7 +199,7 @@ class ConversationDetailApi(Resource):
|
||||
def delete(self, app_model: App, end_user: EndUser, c_id: UUID):
|
||||
"""Delete a specific conversation."""
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
conversation_id = str(c_id)
|
||||
@ -228,7 +228,7 @@ class ConversationRenameApi(Resource):
|
||||
def post(self, app_model: App, end_user: EndUser, c_id: UUID):
|
||||
"""Rename a conversation or auto-generate a name."""
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
conversation_id = str(c_id)
|
||||
|
||||
@ -56,7 +56,7 @@ class MessageListApi(Resource):
|
||||
Retrieves messages with pagination support using first_id.
|
||||
"""
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
query_args = MessageListQuery.model_validate(request.args.to_dict())
|
||||
@ -167,7 +167,7 @@ class MessageSuggestedApi(Resource):
|
||||
"""
|
||||
message_id_str = str(message_id)
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
try:
|
||||
|
||||
@ -2,7 +2,7 @@ import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
@ -37,6 +37,15 @@ from services.errors.llm import InvokeRateLimitError
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_agent_app_streaming(*, app_mode: AppMode, response_mode: str | None) -> bool:
|
||||
"""Agent App runtime is SSE-only until backend blocking runs are supported."""
|
||||
if app_mode != AppMode.AGENT:
|
||||
return response_mode == "streaming"
|
||||
if response_mode == "blocking":
|
||||
raise BadRequest("Agent App only supports streaming response mode.")
|
||||
return True
|
||||
|
||||
|
||||
class CompletionMessagePayload(BaseModel):
|
||||
inputs: dict[str, Any] = Field(description="Input variables for the completion")
|
||||
query: str = Field(default="", description="Query text for completion")
|
||||
@ -171,13 +180,13 @@ class ChatApi(WebApiResource):
|
||||
)
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
payload = ChatMessagePayload.model_validate(web_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
streaming = payload.response_mode == "streaming"
|
||||
streaming = _resolve_agent_app_streaming(app_mode=app_mode, response_mode=payload.response_mode)
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
try:
|
||||
@ -228,7 +237,7 @@ class ChatStopApi(WebApiResource):
|
||||
@web_ns.response(200, "Success", web_ns.models[SimpleResultResponse.__name__])
|
||||
def post(self, app_model: App, end_user: EndUser, task_id: str):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
AppTaskService.stop_task(
|
||||
|
||||
@ -83,7 +83,7 @@ class ConversationListApi(WebApiResource):
|
||||
)
|
||||
def get(self, app_model: App, end_user: EndUser):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
raw_args = request.args.to_dict()
|
||||
@ -129,7 +129,7 @@ class ConversationApi(WebApiResource):
|
||||
)
|
||||
def delete(self, app_model: App, end_user: EndUser, c_id: UUID):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
conversation_id = str(c_id)
|
||||
@ -168,7 +168,7 @@ class ConversationRenameApi(WebApiResource):
|
||||
)
|
||||
def post(self, app_model: App, end_user: EndUser, c_id: UUID):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
conversation_id = str(c_id)
|
||||
@ -206,7 +206,7 @@ class ConversationPinApi(WebApiResource):
|
||||
@web_ns.response(200, "Conversation pinned successfully", web_ns.models[ResultResponse.__name__])
|
||||
def patch(self, app_model: App, end_user: EndUser, c_id: UUID):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
conversation_id = str(c_id)
|
||||
@ -237,7 +237,7 @@ class ConversationUnPinApi(WebApiResource):
|
||||
@web_ns.response(200, "Conversation unpinned successfully", web_ns.models[ResultResponse.__name__])
|
||||
def patch(self, app_model: App, end_user: EndUser, c_id: UUID):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
conversation_id = str(c_id)
|
||||
|
||||
@ -83,7 +83,7 @@ class MessageListApi(WebApiResource):
|
||||
)
|
||||
def get(self, app_model: App, end_user: EndUser):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
raw_args = request.args.to_dict()
|
||||
@ -225,7 +225,7 @@ class MessageSuggestedQuestionApi(WebApiResource):
|
||||
)
|
||||
def get(self, app_model: App, end_user: EndUser, message_id: UUID):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
message_id_str = str(message_id)
|
||||
|
||||
@ -237,7 +237,9 @@ class EasyUIBasedAppConfig(AppConfig):
|
||||
"""
|
||||
|
||||
app_model_config_from: EasyUIBasedAppModelConfigFrom
|
||||
app_model_config_id: str
|
||||
# Optional: an Agent App has no legacy app_model_config row, so the id may be
|
||||
# absent (persistence then stores NULL for the conversation's id).
|
||||
app_model_config_id: str | None = None
|
||||
app_model_config_dict: dict[str, Any]
|
||||
model: ModelConfigEntity
|
||||
prompt_template: PromptTemplateEntity
|
||||
|
||||
0
api/core/app/apps/agent_app/__init__.py
Normal file
0
api/core/app/apps/agent_app/__init__.py
Normal file
106
api/core/app/apps/agent_app/app_config_manager.py
Normal file
106
api/core/app/apps/agent_app/app_config_manager.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""Build the EasyUI-style app config for an Agent App from its Agent Soul.
|
||||
|
||||
An Agent App has no legacy ``app_model_config``: its model / prompt live in the
|
||||
bound Agent Soul snapshot. To ride the existing chat message + SSE pipeline we
|
||||
synthesize an ``app_model_config``-shaped dict from the Soul (model + system
|
||||
prompt) plus any app-level feature flags (opening statement, follow-up, …)
|
||||
stored on ``app_model_config`` when present, then reuse the same sub-managers
|
||||
the chat app type uses.
|
||||
"""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from core.app.app_config.base_app_config_manager import BaseAppConfigManager
|
||||
from core.app.app_config.common.sensitive_word_avoidance.manager import SensitiveWordAvoidanceConfigManager
|
||||
from core.app.app_config.easy_ui_based_app.dataset.manager import DatasetConfigManager
|
||||
from core.app.app_config.easy_ui_based_app.model_config.manager import ModelConfigManager
|
||||
from core.app.app_config.easy_ui_based_app.prompt_template.manager import PromptTemplateConfigManager
|
||||
from core.app.app_config.easy_ui_based_app.variables.manager import BasicVariablesConfigManager
|
||||
from core.app.app_config.entities import (
|
||||
EasyUIBasedAppConfig,
|
||||
EasyUIBasedAppModelConfigFrom,
|
||||
PromptTemplateEntity,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, Conversation
|
||||
|
||||
|
||||
class AgentAppConfig(EasyUIBasedAppConfig):
|
||||
"""Agent App config entity (EasyUI-shaped so it rides the chat pipeline).
|
||||
|
||||
``app_model_config_id`` is inherited as ``str | None``: an Agent App may have
|
||||
no legacy ``app_model_config`` row, in which case persistence stores ``NULL``
|
||||
for the conversation's ``app_model_config_id``.
|
||||
"""
|
||||
|
||||
|
||||
class AgentAppConfigManager(BaseAppConfigManager):
|
||||
@classmethod
|
||||
def get_app_config(
|
||||
cls,
|
||||
*,
|
||||
app_model: App,
|
||||
agent_soul: AgentSoulConfig,
|
||||
app_model_config: AppModelConfig | None = None,
|
||||
conversation: Conversation | None = None,
|
||||
) -> AgentAppConfig:
|
||||
"""Build the Agent App config from the Agent Soul (+ optional feature flags)."""
|
||||
config_dict = cls._synthesize_config_dict(agent_soul, app_model_config)
|
||||
# The synthesized dict is shaped like an app_model_config; the EasyUI
|
||||
# sub-managers type their param as AppModelConfigDict (a TypedDict).
|
||||
typed_config = cast(AppModelConfigDict, config_dict)
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
|
||||
app_config = AgentAppConfig(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
app_mode=app_mode,
|
||||
# The config is derived from the Agent Soul snapshot, not a legacy
|
||||
# app_model_config row; the id is informational only.
|
||||
app_model_config_from=EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG,
|
||||
app_model_config_id=app_model_config.id if app_model_config else None,
|
||||
app_model_config_dict=config_dict,
|
||||
model=ModelConfigManager.convert(config=typed_config),
|
||||
prompt_template=PromptTemplateConfigManager.convert(config=typed_config),
|
||||
sensitive_word_avoidance=SensitiveWordAvoidanceConfigManager.convert(config=config_dict),
|
||||
dataset=DatasetConfigManager.convert(config=typed_config),
|
||||
additional_features=cls.convert_features(config_dict, app_mode),
|
||||
)
|
||||
app_config.variables, app_config.external_data_variables = BasicVariablesConfigManager.convert(
|
||||
config=typed_config
|
||||
)
|
||||
return app_config
|
||||
|
||||
@staticmethod
|
||||
def _synthesize_config_dict(
|
||||
agent_soul: AgentSoulConfig,
|
||||
app_model_config: AppModelConfig | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Shape a Soul + feature flags into an ``app_model_config``-style dict.
|
||||
|
||||
Feature flags (opening statement / follow-up / tts / stt / citations /
|
||||
moderation / annotation) come from ``app_model_config`` when present
|
||||
(Q3: stored there), otherwise defaults; model + prompt always come from
|
||||
the Agent Soul (the single source of truth for those).
|
||||
"""
|
||||
base: dict[str, Any] = dict(app_model_config.to_dict()) if app_model_config else {}
|
||||
|
||||
model = agent_soul.model
|
||||
if model is not None:
|
||||
base["model"] = {
|
||||
"provider": model.model_provider,
|
||||
"name": model.model,
|
||||
"mode": "chat",
|
||||
"completion_params": dict(model.model_settings or {}),
|
||||
}
|
||||
# The Agent Soul system prompt rides the EasyUI "simple" prompt slot; the
|
||||
# agent backend is the real prompt authority, this only feeds the chat
|
||||
# pipeline's bookkeeping (token counting, persistence).
|
||||
base["prompt_type"] = PromptTemplateEntity.PromptType.SIMPLE.value
|
||||
base["pre_prompt"] = agent_soul.prompt.system_prompt or ""
|
||||
# Agent App takes the user message directly; no completion-style inputs form.
|
||||
base.setdefault("user_input_form", [])
|
||||
return base
|
||||
|
||||
|
||||
__all__ = ["AgentAppConfig", "AgentAppConfigManager"]
|
||||
327
api/core/app/apps/agent_app/app_generator.py
Normal file
327
api/core/app/apps/agent_app/app_generator.py
Normal file
@ -0,0 +1,327 @@
|
||||
"""Agent App generator: orchestrate one conversation turn for an Agent App.
|
||||
|
||||
Mirrors the agent_chat generator (conversation + message + queue + streamed
|
||||
response over the EasyUI chat pipeline), but the backing config comes from the
|
||||
bound Agent Soul and the answer is produced by ``AgentAppRunner`` calling the
|
||||
dify-agent backend rather than an in-process LLM/ReAct loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Generator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, current_app
|
||||
from sqlalchemy import select
|
||||
|
||||
from clients.agent_backend import AgentBackendRunEventAdapter
|
||||
from clients.agent_backend.factory import create_agent_backend_run_client
|
||||
from configs import dify_config
|
||||
from constants import UUID_NIL
|
||||
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
|
||||
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
|
||||
from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
|
||||
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
AgentAppGenerateEntity,
|
||||
DifyRunContext,
|
||||
InvokeFrom,
|
||||
UserFrom,
|
||||
)
|
||||
from core.app.llm.model_access import build_dify_model_access
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App, EndUser, Message
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentAppGeneratorError(ValueError):
|
||||
"""Raised when an Agent App turn cannot be set up."""
|
||||
|
||||
|
||||
class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
def generate(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
user: Account | EndUser,
|
||||
args: Mapping[str, Any],
|
||||
invoke_from: InvokeFrom,
|
||||
streaming: bool = True,
|
||||
) -> Mapping[str, Any] | Generator[Mapping | str, None, None]:
|
||||
if not streaming:
|
||||
raise AgentAppGeneratorError("Agent App only supports streaming mode")
|
||||
|
||||
query = args.get("query")
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
raise AgentAppGeneratorError("query is required")
|
||||
query = query.replace("\x00", "")
|
||||
inputs = args["inputs"]
|
||||
|
||||
# Resolve the bound roster Agent + its published Agent Soul snapshot.
|
||||
agent, snapshot, agent_soul = self._resolve_agent(app_model)
|
||||
|
||||
conversation = None
|
||||
conversation_id = args.get("conversation_id")
|
||||
if conversation_id:
|
||||
conversation = ConversationService.get_conversation(
|
||||
app_model=app_model, conversation_id=conversation_id, user=user
|
||||
)
|
||||
|
||||
# Build the EasyUI-shaped config from the Agent Soul so the chat pipeline
|
||||
# can persist usage; the answer itself comes from the agent backend.
|
||||
app_model_config = app_model.app_model_config
|
||||
app_config = AgentAppConfigManager.get_app_config(
|
||||
app_model=app_model,
|
||||
agent_soul=agent_soul,
|
||||
app_model_config=app_model_config,
|
||||
conversation=conversation,
|
||||
)
|
||||
model_conf = ModelConfigConverter.convert(app_config)
|
||||
|
||||
trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id)
|
||||
|
||||
application_generate_entity = AgentAppGenerateEntity(
|
||||
task_id=str(uuid.uuid4()),
|
||||
app_config=app_config,
|
||||
model_conf=model_conf,
|
||||
conversation_id=conversation.id if conversation else None,
|
||||
inputs=self._prepare_user_inputs(
|
||||
user_inputs=inputs, variables=app_config.variables, tenant_id=app_model.tenant_id
|
||||
),
|
||||
query=query,
|
||||
files=[],
|
||||
parent_message_id=(
|
||||
args.get("parent_message_id")
|
||||
if invoke_from not in {InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI}
|
||||
else UUID_NIL
|
||||
),
|
||||
user_id=user.id,
|
||||
stream=streaming,
|
||||
invoke_from=invoke_from,
|
||||
extras={"auto_generate_conversation_name": args.get("auto_generate_name", True)},
|
||||
call_depth=0,
|
||||
trace_manager=trace_manager,
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=snapshot.id,
|
||||
)
|
||||
|
||||
conversation, message = self._init_generate_records(application_generate_entity, conversation)
|
||||
|
||||
queue_manager = MessageBasedAppQueueManager(
|
||||
task_id=application_generate_entity.task_id,
|
||||
user_id=application_generate_entity.user_id,
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
conversation_id=conversation.id,
|
||||
app_mode=conversation.mode,
|
||||
message_id=message.id,
|
||||
)
|
||||
|
||||
context = contextvars.copy_context()
|
||||
worker_thread = threading.Thread(
|
||||
target=self._generate_worker,
|
||||
kwargs={
|
||||
"flask_app": current_app._get_current_object(), # type: ignore
|
||||
"context": context,
|
||||
"application_generate_entity": application_generate_entity,
|
||||
"queue_manager": queue_manager,
|
||||
"conversation_id": conversation.id,
|
||||
"message_id": message.id,
|
||||
"user_from": UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER,
|
||||
},
|
||||
)
|
||||
worker_thread.start()
|
||||
|
||||
response = self._handle_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
queue_manager=queue_manager,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
user=user,
|
||||
stream=streaming,
|
||||
)
|
||||
return AgentAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
|
||||
|
||||
def _generate_worker(
|
||||
self,
|
||||
*,
|
||||
flask_app: Flask,
|
||||
context: contextvars.Context,
|
||||
application_generate_entity: AgentAppGenerateEntity,
|
||||
queue_manager: AppQueueManager,
|
||||
conversation_id: str,
|
||||
message_id: str,
|
||||
user_from: UserFrom,
|
||||
) -> None:
|
||||
from libs.flask_utils import preserve_flask_contexts
|
||||
|
||||
with preserve_flask_contexts(flask_app, context_vars=context):
|
||||
try:
|
||||
conversation = self._get_conversation(conversation_id)
|
||||
message = self._get_message(message_id)
|
||||
app_config = application_generate_entity.app_config
|
||||
|
||||
# Apply app-level input guards (content moderation + annotation
|
||||
# reply) before reaching the Agent backend, mirroring the EasyUI
|
||||
# chat / agent-chat runners. These can short-circuit the turn.
|
||||
app_model = db.session.get(App, app_config.app_id)
|
||||
if app_model is None:
|
||||
raise AgentAppGeneratorError("App not found")
|
||||
handled, query = self._run_input_guards(
|
||||
application_generate_entity=application_generate_entity,
|
||||
app_model=app_model,
|
||||
message=message,
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
if handled:
|
||||
return
|
||||
|
||||
dify_context = DifyRunContext(
|
||||
tenant_id=app_config.tenant_id,
|
||||
app_id=app_config.app_id,
|
||||
user_id=application_generate_entity.user_id,
|
||||
user_from=user_from,
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
)
|
||||
credentials_provider, _ = build_dify_model_access(dify_context)
|
||||
_, _, agent_soul = self._resolve_agent_by_id(
|
||||
tenant_id=app_config.tenant_id,
|
||||
agent_id=application_generate_entity.agent_id,
|
||||
snapshot_id=application_generate_entity.agent_config_snapshot_id,
|
||||
)
|
||||
|
||||
runner = AgentAppRunner(
|
||||
request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider),
|
||||
agent_backend_client=create_agent_backend_run_client(
|
||||
base_url=dify_config.AGENT_BACKEND_BASE_URL,
|
||||
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
|
||||
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
|
||||
),
|
||||
event_adapter=AgentBackendRunEventAdapter(),
|
||||
session_store=AgentAppRuntimeSessionStore(),
|
||||
)
|
||||
runner.run(
|
||||
dify_context=dify_context,
|
||||
agent_id=application_generate_entity.agent_id,
|
||||
agent_config_snapshot_id=application_generate_entity.agent_config_snapshot_id,
|
||||
agent_soul=agent_soul,
|
||||
conversation_id=conversation.id,
|
||||
query=query,
|
||||
message_id=message.id,
|
||||
model_name=application_generate_entity.model_conf.model,
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
except GenerateTaskStoppedError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.exception("Unknown Error in Agent App generate worker")
|
||||
queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER)
|
||||
finally:
|
||||
db.session.close()
|
||||
|
||||
def _run_input_guards(
|
||||
self,
|
||||
*,
|
||||
application_generate_entity: AgentAppGenerateEntity,
|
||||
app_model: App,
|
||||
message: Message,
|
||||
queue_manager: AppQueueManager,
|
||||
) -> tuple[bool, str]:
|
||||
"""Apply input moderation + annotation reply before the backend call.
|
||||
|
||||
Returns ``(handled, query)``: when ``handled`` is True a direct answer
|
||||
has already been published (a blocked/preset moderation response or a
|
||||
matched annotation) and the backend turn must be skipped. Otherwise
|
||||
``query`` is the possibly moderation-overridden query to send onward.
|
||||
"""
|
||||
from core.app.apps.agent_app.app_runner import publish_text_answer
|
||||
from core.app.entities.queue_entities import QueueAnnotationReplyEvent
|
||||
from core.app.features.annotation_reply.annotation_reply import AnnotationReplyFeature
|
||||
from core.moderation.base import ModerationError
|
||||
from core.moderation.input_moderation import InputModeration
|
||||
|
||||
app_config = application_generate_entity.app_config
|
||||
model_name = application_generate_entity.model_conf.model
|
||||
query = application_generate_entity.query
|
||||
|
||||
# content moderation (sensitive_word_avoidance); a blocked input yields a
|
||||
# preset answer, an "overridden" action returns a sanitized query.
|
||||
try:
|
||||
_, _, query = InputModeration().check(
|
||||
app_id=app_config.app_id,
|
||||
tenant_id=app_config.tenant_id,
|
||||
app_config=app_config,
|
||||
inputs=dict(application_generate_entity.inputs),
|
||||
query=query or "",
|
||||
message_id=message.id,
|
||||
trace_manager=application_generate_entity.trace_manager,
|
||||
)
|
||||
except ModerationError as e:
|
||||
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e))
|
||||
return True, query
|
||||
|
||||
# annotation reply: a matching annotation answers the turn deterministically.
|
||||
if query:
|
||||
annotation_reply = AnnotationReplyFeature().query(
|
||||
app_record=app_model,
|
||||
message=message,
|
||||
query=query,
|
||||
user_id=application_generate_entity.user_id,
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
)
|
||||
if annotation_reply:
|
||||
queue_manager.publish(
|
||||
QueueAnnotationReplyEvent(message_annotation_id=annotation_reply.id),
|
||||
PublishFrom.APPLICATION_MANAGER,
|
||||
)
|
||||
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=annotation_reply.content)
|
||||
return True, query
|
||||
|
||||
return False, query
|
||||
|
||||
def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
agent = db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.app_id == app_model.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
return self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_by_id(
|
||||
*, tenant_id: str, agent_id: str, snapshot_id: str | None
|
||||
) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id))
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent not found")
|
||||
if not snapshot_id:
|
||||
raise AgentAppGeneratorError("Agent has no published version")
|
||||
snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id))
|
||||
if snapshot is None:
|
||||
raise AgentAppGeneratorError("Agent published version not found")
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return agent, snapshot, agent_soul
|
||||
|
||||
|
||||
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
|
||||
200
api/core/app/apps/agent_app/app_runner.py
Normal file
200
api/core/app/apps/agent_app/app_runner.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""Agent App runner: drive one conversation turn through the dify-agent backend.
|
||||
|
||||
Unlike the legacy ``AgentChatAppRunner`` (which runs an in-process ReAct loop),
|
||||
this runner delegates to the Agent backend: build the run request from the
|
||||
Agent Soul + conversation, create the run, consume its event stream, and
|
||||
republish the assistant answer as chat queue events so the existing
|
||||
EasyUI chat task pipeline persists the message and streams SSE. The conversation
|
||||
``session_snapshot`` is saved on success for multi-turn continuity (S3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendError,
|
||||
AgentBackendInternalEventType,
|
||||
AgentBackendRunClient,
|
||||
AgentBackendRunEventAdapter,
|
||||
AgentBackendRunSucceededInternalEvent,
|
||||
AgentBackendStreamInternalEvent,
|
||||
)
|
||||
from core.app.apps.agent_app.runtime_request_builder import (
|
||||
AgentAppRuntimeBuildContext,
|
||||
AgentAppRuntimeRequestBuilder,
|
||||
)
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext
|
||||
from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
|
||||
"""Publish a complete assistant answer as one chunk + message-end.
|
||||
|
||||
The EasyUI chat task pipeline consumes a QueueLLMChunkEvent stream followed
|
||||
by a QueueMessageEndEvent; emitting the whole answer as a single chunk lets
|
||||
both the backend-produced answer and short-circuited answers (moderation /
|
||||
annotation reply) share the exact same persistence + SSE path.
|
||||
"""
|
||||
chunk = LLMResultChunk(
|
||||
model=model_name,
|
||||
prompt_messages=[],
|
||||
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=answer)),
|
||||
)
|
||||
queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER)
|
||||
queue_manager.publish(
|
||||
QueueMessageEndEvent(
|
||||
llm_result=LLMResult(
|
||||
model=model_name,
|
||||
prompt_messages=[],
|
||||
message=AssistantPromptMessage(content=answer),
|
||||
usage=LLMUsage.empty_usage(),
|
||||
),
|
||||
),
|
||||
PublishFrom.APPLICATION_MANAGER,
|
||||
)
|
||||
|
||||
|
||||
class AgentAppRunner:
|
||||
"""Runs one Agent App conversation turn against the Agent backend."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
request_builder: AgentAppRuntimeRequestBuilder,
|
||||
agent_backend_client: AgentBackendRunClient,
|
||||
event_adapter: AgentBackendRunEventAdapter,
|
||||
session_store: AgentAppRuntimeSessionStore,
|
||||
) -> None:
|
||||
self._request_builder = request_builder
|
||||
self._agent_backend_client = agent_backend_client
|
||||
self._event_adapter = event_adapter
|
||||
self._session_store = session_store
|
||||
|
||||
def run(
|
||||
self,
|
||||
*,
|
||||
dify_context: DifyRunContext,
|
||||
agent_id: str,
|
||||
agent_config_snapshot_id: str,
|
||||
agent_soul: AgentSoulConfig,
|
||||
conversation_id: str,
|
||||
query: str,
|
||||
message_id: str,
|
||||
model_name: str,
|
||||
queue_manager: AppQueueManager,
|
||||
) -> None:
|
||||
scope = AgentAppSessionScope(
|
||||
tenant_id=dify_context.tenant_id,
|
||||
app_id=dify_context.app_id,
|
||||
conversation_id=conversation_id,
|
||||
agent_id=agent_id,
|
||||
agent_config_snapshot_id=agent_config_snapshot_id,
|
||||
)
|
||||
session_snapshot = self._session_store.load_active_snapshot(scope)
|
||||
|
||||
runtime = self._request_builder.build(
|
||||
AgentAppRuntimeBuildContext(
|
||||
dify_context=dify_context,
|
||||
agent_id=agent_id,
|
||||
agent_config_snapshot_id=agent_config_snapshot_id,
|
||||
agent_soul=agent_soul,
|
||||
conversation_id=conversation_id,
|
||||
user_query=query,
|
||||
idempotency_key=message_id,
|
||||
session_snapshot=session_snapshot,
|
||||
)
|
||||
)
|
||||
|
||||
create_response = self._agent_backend_client.create_run(runtime.request)
|
||||
terminal = self._consume_stream(create_response.run_id, queue_manager=queue_manager)
|
||||
|
||||
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
|
||||
error = getattr(terminal, "error", None) or "Agent backend run did not complete successfully."
|
||||
raise AgentBackendError(str(error))
|
||||
|
||||
answer = self._extract_answer(terminal.output)
|
||||
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
|
||||
self._save_session(scope=scope, backend_run_id=terminal.run_id, snapshot=terminal.session_snapshot)
|
||||
|
||||
def _consume_stream(self, run_id: str, *, queue_manager: AppQueueManager):
|
||||
terminal = None
|
||||
for public_event in self._agent_backend_client.stream_events(run_id):
|
||||
if queue_manager.is_stopped():
|
||||
self._cancel_run(run_id)
|
||||
raise GenerateTaskStoppedError()
|
||||
for internal_event in self._event_adapter.adapt(public_event):
|
||||
if queue_manager.is_stopped():
|
||||
self._cancel_run(run_id)
|
||||
raise GenerateTaskStoppedError()
|
||||
if internal_event.type in (
|
||||
AgentBackendInternalEventType.RUN_STARTED,
|
||||
AgentBackendInternalEventType.STREAM_EVENT,
|
||||
):
|
||||
# Stream deltas are accumulated by the backend into the
|
||||
# terminal output; token-level forwarding is an S3 refinement.
|
||||
if isinstance(internal_event, AgentBackendStreamInternalEvent):
|
||||
continue
|
||||
continue
|
||||
terminal = internal_event
|
||||
break
|
||||
if terminal is not None:
|
||||
break
|
||||
return terminal
|
||||
|
||||
def _cancel_run(self, run_id: str) -> None:
|
||||
try:
|
||||
self._agent_backend_client.cancel_run(run_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to cancel stopped Agent App backend run: run_id=%s", run_id, exc_info=True)
|
||||
|
||||
def _publish_answer(self, *, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
|
||||
# MVP: emit the full answer as a single chunk + message-end. The chat
|
||||
# task pipeline streams the chunk over SSE and persists the message.
|
||||
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
|
||||
|
||||
def _save_session(self, *, scope: AgentAppSessionScope, backend_run_id: str, snapshot: Any) -> None:
|
||||
try:
|
||||
self._session_store.save_active_snapshot(scope=scope, backend_run_id=backend_run_id, snapshot=snapshot)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist Agent App conversation session snapshot: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s",
|
||||
scope.tenant_id,
|
||||
scope.app_id,
|
||||
scope.conversation_id,
|
||||
scope.agent_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_answer(output: JsonValue) -> str:
|
||||
"""Normalize the backend's terminal output to assistant text.
|
||||
|
||||
Free-text Agent Apps return a plain string; if a structured output is
|
||||
configured the value is a JSON object, which we serialize so the chat
|
||||
message always has a string body.
|
||||
"""
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
if isinstance(output, dict):
|
||||
text = output.get("text")
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return json.dumps(output, ensure_ascii=False)
|
||||
return json.dumps(output, ensure_ascii=False)
|
||||
|
||||
|
||||
__all__ = ["AgentAppRunner", "publish_text_answer"]
|
||||
15
api/core/app/apps/agent_app/generate_response_converter.py
Normal file
15
api/core/app/apps/agent_app/generate_response_converter.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""Response converter for the Agent App type.
|
||||
|
||||
The Agent App streams the same chatbot response shape as the chat / agent-chat
|
||||
app types, so it reuses that converter wholesale; kept as a distinct subclass so
|
||||
the app type owns its converter and can diverge later.
|
||||
"""
|
||||
|
||||
from core.app.apps.agent_chat.generate_response_converter import AgentChatAppGenerateResponseConverter
|
||||
|
||||
|
||||
class AgentAppGenerateResponseConverter(AgentChatAppGenerateResponseConverter):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["AgentAppGenerateResponseConverter"]
|
||||
177
api/core/app/apps/agent_app/runtime_request_builder.py
Normal file
177
api/core/app/apps/agent_app/runtime_request_builder.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""Build dify-agent run requests for one Agent App conversation turn.
|
||||
|
||||
Mirrors the workflow ``WorkflowAgentRuntimeRequestBuilder`` but for the Agent
|
||||
App surface: the user prompt is the chat message (no workflow-node job / no
|
||||
previous-node context), and multi-turn continuity flows through the
|
||||
conversation-keyed ``session_snapshot`` plus the history layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.protocol import CreateRunRequest
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendAgentAppRunInput,
|
||||
AgentBackendModelConfig,
|
||||
AgentBackendRunRequestBuilder,
|
||||
redact_for_agent_backend_log,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext
|
||||
from core.workflow.nodes.agent_v2.plugin_tools_builder import (
|
||||
WorkflowAgentPluginToolsBuilder,
|
||||
WorkflowAgentPluginToolsBuildError,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.provider_ids import ModelProviderID
|
||||
|
||||
|
||||
class AgentAppRuntimeRequestBuildError(ValueError):
|
||||
"""Raised when Agent App state cannot be mapped to a valid run request."""
|
||||
|
||||
def __init__(self, error_code: str, message: str) -> None:
|
||||
self.error_code = error_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CredentialsProvider(Protocol):
|
||||
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentAppRuntimeBuildContext:
|
||||
dify_context: DifyRunContext
|
||||
agent_id: str
|
||||
agent_config_snapshot_id: str
|
||||
agent_soul: AgentSoulConfig
|
||||
conversation_id: str
|
||||
user_query: str
|
||||
idempotency_key: str
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentAppRuntimeRequest:
|
||||
request: CreateRunRequest
|
||||
redacted_request: dict[str, Any]
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
class AgentAppRuntimeRequestBuilder:
|
||||
"""Build dify-agent run requests from Agent App conversation state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
credentials_provider: CredentialsProvider,
|
||||
request_builder: AgentBackendRunRequestBuilder | None = None,
|
||||
plugin_tools_builder: WorkflowAgentPluginToolsBuilder | None = None,
|
||||
) -> None:
|
||||
self._credentials_provider = credentials_provider
|
||||
self._request_builder = request_builder or AgentBackendRunRequestBuilder()
|
||||
self._plugin_tools_builder = plugin_tools_builder or WorkflowAgentPluginToolsBuilder()
|
||||
|
||||
def build(self, context: AgentAppRuntimeBuildContext) -> AgentAppRuntimeRequest:
|
||||
agent_soul = context.agent_soul
|
||||
if agent_soul.model is None:
|
||||
raise AgentAppRuntimeRequestBuildError(
|
||||
"agent_model_not_configured",
|
||||
"Agent App requires the Agent Soul model to be configured.",
|
||||
)
|
||||
|
||||
metadata = self._build_metadata(context)
|
||||
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
|
||||
try:
|
||||
tools_layer = self._plugin_tools_builder.build(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
app_id=context.dify_context.app_id,
|
||||
user_id=context.dify_context.user_id,
|
||||
tools=agent_soul.tools,
|
||||
invoke_from=context.dify_context.invoke_from,
|
||||
)
|
||||
except WorkflowAgentPluginToolsBuildError as error:
|
||||
raise AgentAppRuntimeRequestBuildError(error.error_code, str(error)) from error
|
||||
if tools_layer is not None:
|
||||
metadata["agent_tools"] = {
|
||||
"dify_tool_count": len(tools_layer.tools),
|
||||
"dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools],
|
||||
}
|
||||
|
||||
request = self._request_builder.build_for_agent_app(
|
||||
AgentBackendAgentAppRunInput(
|
||||
model=AgentBackendModelConfig(
|
||||
plugin_id=self._plugin_daemon_plugin_id(
|
||||
plugin_id=agent_soul.model.plugin_id,
|
||||
model_provider=agent_soul.model.model_provider,
|
||||
),
|
||||
model_provider=self._plugin_daemon_provider_name(agent_soul.model.model_provider),
|
||||
model=agent_soul.model.model,
|
||||
credentials=self._normalize_credentials(credentials),
|
||||
model_settings=agent_soul.model.model_settings,
|
||||
),
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
user_id=context.dify_context.user_id,
|
||||
app_id=context.dify_context.app_id,
|
||||
conversation_id=context.conversation_id,
|
||||
agent_id=context.agent_id,
|
||||
agent_config_version_id=context.agent_config_snapshot_id,
|
||||
invoke_from="agent_app",
|
||||
),
|
||||
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
|
||||
user_prompt=context.user_query,
|
||||
tools=tools_layer,
|
||||
session_snapshot=context.session_snapshot,
|
||||
idempotency_key=context.idempotency_key,
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
|
||||
return AgentAppRuntimeRequest(request=request, redacted_request=redacted, metadata=metadata)
|
||||
|
||||
@staticmethod
|
||||
def _build_metadata(context: AgentAppRuntimeBuildContext) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": context.dify_context.tenant_id,
|
||||
"app_id": context.dify_context.app_id,
|
||||
"conversation_id": context.conversation_id,
|
||||
"agent_id": context.agent_id,
|
||||
"agent_config_snapshot_id": context.agent_config_snapshot_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str:
|
||||
"""Return the transport plugin id expected by plugin-daemon headers."""
|
||||
if plugin_id.count("/") == 1:
|
||||
return plugin_id
|
||||
if plugin_id:
|
||||
return ModelProviderID(plugin_id).plugin_id
|
||||
return ModelProviderID(model_provider).plugin_id
|
||||
|
||||
@staticmethod
|
||||
def _plugin_daemon_provider_name(model_provider: str) -> str:
|
||||
"""Return the provider name expected by plugin-daemon dispatch payloads."""
|
||||
return ModelProviderID(model_provider).provider_name
|
||||
|
||||
@staticmethod
|
||||
def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]:
|
||||
normalized: dict[str, str | int | float | bool | None] = {}
|
||||
for key, value in credentials.items():
|
||||
if isinstance(value, str | int | float | bool) or value is None:
|
||||
normalized[key] = value
|
||||
else:
|
||||
normalized[key] = str(value)
|
||||
return normalized
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentAppRuntimeBuildContext",
|
||||
"AgentAppRuntimeRequest",
|
||||
"AgentAppRuntimeRequestBuildError",
|
||||
"AgentAppRuntimeRequestBuilder",
|
||||
]
|
||||
106
api/core/app/apps/agent_app/session_store.py
Normal file
106
api/core/app/apps/agent_app/session_store.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""Conversation-keyed Agent backend session store for the Agent App type.
|
||||
|
||||
Shares the unified ``agent_runtime_sessions`` table with the workflow Agent
|
||||
Node store, but owns rows with ``owner_type = conversation``: one Agent App
|
||||
conversation maps to one Agent session, so multi-turn chat re-enters the same
|
||||
``session_snapshot``. Cross-conversation memory (PRD Global / Per app) is a
|
||||
phase-2 concern and not modeled here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.agent import (
|
||||
AgentRuntimeSession,
|
||||
AgentRuntimeSessionOwnerType,
|
||||
AgentRuntimeSessionStatus,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentAppSessionScope:
|
||||
"""Identity of one Agent App conversation session."""
|
||||
|
||||
tenant_id: str
|
||||
app_id: str
|
||||
conversation_id: str
|
||||
agent_id: str
|
||||
agent_config_snapshot_id: str
|
||||
|
||||
|
||||
class AgentAppRuntimeSessionStore:
|
||||
"""Persists Agent backend session snapshots for Agent App conversations."""
|
||||
|
||||
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(self._active_stmt(scope))
|
||||
if row is None:
|
||||
return None
|
||||
return CompositorSessionSnapshot.model_validate_json(row.session_snapshot)
|
||||
|
||||
def save_active_snapshot(
|
||||
self,
|
||||
*,
|
||||
scope: AgentAppSessionScope,
|
||||
backend_run_id: str,
|
||||
snapshot: CompositorSessionSnapshot | None,
|
||||
) -> None:
|
||||
if snapshot is None:
|
||||
return
|
||||
snapshot_json = snapshot.model_dump_json()
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(self._scope_stmt(scope))
|
||||
if row is None:
|
||||
row = AgentRuntimeSession(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id=scope.agent_id,
|
||||
agent_config_snapshot_id=scope.agent_config_snapshot_id,
|
||||
conversation_id=scope.conversation_id,
|
||||
backend_run_id=backend_run_id,
|
||||
session_snapshot=snapshot_json,
|
||||
composition_layer_specs="[]",
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.backend_run_id = backend_run_id
|
||||
row.session_snapshot = snapshot_json
|
||||
row.status = AgentRuntimeSessionStatus.ACTIVE
|
||||
row.cleaned_at = None
|
||||
session.commit()
|
||||
|
||||
def mark_cleaned(self, *, scope: AgentAppSessionScope, backend_run_id: str | None = None) -> None:
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(self._active_stmt(scope))
|
||||
if row is None:
|
||||
return
|
||||
if backend_run_id is not None:
|
||||
row.backend_run_id = backend_run_id
|
||||
row.status = AgentRuntimeSessionStatus.CLEANED
|
||||
row.cleaned_at = naive_utc_now()
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _scope_stmt(scope: AgentAppSessionScope):
|
||||
return select(AgentRuntimeSession).where(
|
||||
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
AgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||
AgentRuntimeSession.conversation_id == scope.conversation_id,
|
||||
AgentRuntimeSession.agent_id == scope.agent_id,
|
||||
AgentRuntimeSession.agent_config_snapshot_id == scope.agent_config_snapshot_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _active_stmt(cls, scope: AgentAppSessionScope):
|
||||
return cls._scope_stmt(scope).where(AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE)
|
||||
|
||||
|
||||
__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope"]
|
||||
@ -134,6 +134,10 @@ class AppQueueManager(ABC):
|
||||
self._check_for_sqlalchemy_models(event.model_dump())
|
||||
self._publish(event, pub_from)
|
||||
|
||||
def is_stopped(self) -> bool:
|
||||
"""Return whether the current task has been manually stopped."""
|
||||
return self._is_stopped()
|
||||
|
||||
@abstractmethod
|
||||
def _publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None:
|
||||
"""
|
||||
|
||||
@ -200,6 +200,21 @@ class AgentChatAppGenerateEntity(ConversationAppGenerateEntity, EasyUIBasedAppGe
|
||||
pass
|
||||
|
||||
|
||||
class AgentAppGenerateEntity(ChatAppGenerateEntity):
|
||||
"""
|
||||
Agent App (new Agent app type) Generate Entity.
|
||||
|
||||
Subclasses ``ChatAppGenerateEntity`` so it rides the exact same EasyUI chat
|
||||
pipeline (generator, task pipeline, message cycle) without widening every
|
||||
accepted-entity union. The answer is produced by the dify-agent backend
|
||||
rather than an in-process LLM call; ``model_conf`` is synthesized from the
|
||||
bound Agent Soul model so the chat task pipeline can persist usage.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
agent_config_snapshot_id: str
|
||||
|
||||
|
||||
class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity):
|
||||
"""
|
||||
Advanced Chat Application Generate Entity.
|
||||
|
||||
@ -10,6 +10,7 @@ from clients.agent_backend.request_builder import CleanupLayerSpec
|
||||
from core.db.session_factory import session_factory
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.agent import (
|
||||
AgentRuntimeSessionOwnerType,
|
||||
WorkflowAgentRuntimeSession,
|
||||
WorkflowAgentRuntimeSessionStatus,
|
||||
)
|
||||
@ -83,13 +84,16 @@ class WorkflowAgentRuntimeSessionStore:
|
||||
scope=WorkflowAgentSessionScope(
|
||||
tenant_id=row.tenant_id,
|
||||
app_id=row.app_id,
|
||||
workflow_id=row.workflow_id,
|
||||
# These columns are nullable on the unified runtime-session
|
||||
# table (workflow_run ⊕ conversation owner), but are always
|
||||
# populated for a workflow-owned row; coerce for the typed scope.
|
||||
workflow_id=row.workflow_id or "",
|
||||
workflow_run_id=row.workflow_run_id,
|
||||
node_id=row.node_id,
|
||||
node_id=row.node_id or "",
|
||||
node_execution_id=row.node_execution_id or "",
|
||||
binding_id=row.binding_id,
|
||||
binding_id=row.binding_id or "",
|
||||
agent_id=row.agent_id,
|
||||
agent_config_snapshot_id=row.agent_config_snapshot_id,
|
||||
agent_config_snapshot_id=row.agent_config_snapshot_id or "",
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||
backend_run_id=row.backend_run_id,
|
||||
@ -125,6 +129,7 @@ class WorkflowAgentRuntimeSessionStore:
|
||||
row = WorkflowAgentRuntimeSession(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
|
||||
workflow_id=scope.workflow_id,
|
||||
workflow_run_id=scope.workflow_run_id,
|
||||
node_id=scope.node_id,
|
||||
|
||||
@ -0,0 +1,146 @@
|
||||
"""unify agent runtime sessions table
|
||||
|
||||
Revision ID: 121e7346074d
|
||||
Revises: 7885bd53f9a9
|
||||
Create Date: 2026-05-29 10:54:19.400054
|
||||
|
||||
Unifies the workflow-only ``workflow_agent_runtime_sessions`` table into an
|
||||
owner-agnostic ``agent_runtime_sessions`` table that serves both workflow
|
||||
Agent Node runs (owner_type=workflow_run) and Agent App conversations
|
||||
(owner_type=conversation). The feature is unreleased, so the old table is
|
||||
dropped rather than migrated (no data to preserve).
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models as models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "121e7346074d"
|
||||
# Rebased onto main's head after merge: credential-visibility (a1b2c3d4e5f6)
|
||||
# landed on top of the lifecycle migration (7885bd53f9a9) this one also branched
|
||||
# from, which created two alembic heads. The two migrations are unrelated, so
|
||||
# chain this one after the credential migration to restore a single linear head.
|
||||
down_revision = "a1b2c3d4e5f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _is_pg() -> bool:
|
||||
return op.get_bind().dialect.name == "postgresql"
|
||||
|
||||
|
||||
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
|
||||
kwargs: dict[str, object] = {"nullable": nullable, "primary_key": primary_key}
|
||||
if primary_key and _is_pg():
|
||||
kwargs["server_default"] = sa.text("uuidv7()")
|
||||
return sa.Column(name, models.types.StringUUID(), **kwargs)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the unreleased workflow-only table; recreate as the unified table.
|
||||
op.drop_table("workflow_agent_runtime_sessions")
|
||||
|
||||
op.create_table(
|
||||
"agent_runtime_sessions",
|
||||
_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("owner_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("backend_run_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("session_snapshot", models.types.LongText(), nullable=False),
|
||||
# Workflow-owner columns (NULL for conversation owner).
|
||||
sa.Column("workflow_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("workflow_run_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("node_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("node_execution_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("binding_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("agent_config_snapshot_id", models.types.StringUUID(), nullable=True),
|
||||
# MySQL rejects defaults on TEXT; the ORM always supplies this value.
|
||||
sa.Column("composition_layer_specs", models.types.LongText(), nullable=False),
|
||||
# Conversation-owner column (NULL for workflow owner). Conversation
|
||||
# sessions are also scoped by agent_config_snapshot_id so a Soul version
|
||||
# change never resumes an incompatible backend snapshot.
|
||||
sa.Column("conversation_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), server_default=sa.text("'active'"), nullable=False),
|
||||
sa.Column("cleaned_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_runtime_session_pkey")),
|
||||
)
|
||||
with op.batch_alter_table("agent_runtime_sessions", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
"agent_runtime_session_workflow_scope_unique",
|
||||
["tenant_id", "workflow_run_id", "node_id", "binding_id", "agent_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
|
||||
)
|
||||
batch_op.create_index(
|
||||
"agent_runtime_session_conversation_scope_unique",
|
||||
["tenant_id", "conversation_id", "agent_id", "agent_config_snapshot_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("conversation_id IS NOT NULL"),
|
||||
)
|
||||
batch_op.create_index(
|
||||
"agent_runtime_session_workflow_lookup_idx",
|
||||
["tenant_id", "workflow_run_id", "node_id", "status"],
|
||||
)
|
||||
batch_op.create_index(
|
||||
"agent_runtime_session_conversation_lookup_idx",
|
||||
["tenant_id", "conversation_id", "status"],
|
||||
)
|
||||
batch_op.create_index("agent_runtime_session_backend_run_idx", ["backend_run_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("agent_runtime_sessions", schema=None) as batch_op:
|
||||
batch_op.drop_index("agent_runtime_session_backend_run_idx")
|
||||
batch_op.drop_index("agent_runtime_session_conversation_lookup_idx")
|
||||
batch_op.drop_index("agent_runtime_session_workflow_lookup_idx")
|
||||
batch_op.drop_index(
|
||||
"agent_runtime_session_conversation_scope_unique",
|
||||
postgresql_where=sa.text("conversation_id IS NOT NULL"),
|
||||
)
|
||||
batch_op.drop_index(
|
||||
"agent_runtime_session_workflow_scope_unique",
|
||||
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
|
||||
)
|
||||
op.drop_table("agent_runtime_sessions")
|
||||
|
||||
op.create_table(
|
||||
"workflow_agent_runtime_sessions",
|
||||
_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("workflow_run_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("node_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("node_execution_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("binding_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("agent_config_snapshot_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("backend_run_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("session_snapshot", models.types.LongText(), nullable=False),
|
||||
sa.Column("composition_layer_specs", models.types.LongText(), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), server_default=sa.text("'active'"), nullable=False),
|
||||
sa.Column("cleaned_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("workflow_agent_runtime_session_pkey")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"workflow_run_id",
|
||||
"node_id",
|
||||
"binding_id",
|
||||
"agent_id",
|
||||
name=op.f("workflow_agent_runtime_session_scope_unique"),
|
||||
),
|
||||
)
|
||||
with op.batch_alter_table("workflow_agent_runtime_sessions", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
"workflow_agent_runtime_session_lookup_idx",
|
||||
["tenant_id", "workflow_run_id", "node_id", "status"],
|
||||
)
|
||||
batch_op.create_index("workflow_agent_runtime_session_backend_run_idx", ["backend_run_id"])
|
||||
@ -15,6 +15,9 @@ from .agent import (
|
||||
AgentConfigSnapshot,
|
||||
AgentIconType,
|
||||
AgentKind,
|
||||
AgentRuntimeSession,
|
||||
AgentRuntimeSessionOwnerType,
|
||||
AgentRuntimeSessionStatus,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
@ -149,6 +152,9 @@ __all__ = [
|
||||
"AgentConfigSnapshot",
|
||||
"AgentIconType",
|
||||
"AgentKind",
|
||||
"AgentRuntimeSession",
|
||||
"AgentRuntimeSessionOwnerType",
|
||||
"AgentRuntimeSessionStatus",
|
||||
"AgentScope",
|
||||
"AgentSource",
|
||||
"AgentStatus",
|
||||
|
||||
@ -92,15 +92,33 @@ class WorkflowAgentBindingType(StrEnum):
|
||||
INLINE_AGENT = "inline_agent"
|
||||
|
||||
|
||||
class WorkflowAgentRuntimeSessionStatus(StrEnum):
|
||||
"""Lifecycle state of an Agent backend session snapshot owned by a workflow run."""
|
||||
class AgentRuntimeSessionStatus(StrEnum):
|
||||
"""Lifecycle state of an Agent backend session snapshot.
|
||||
|
||||
# Snapshot can be reused by a later Agent run in the same workflow run.
|
||||
Owner-agnostic: applies both to workflow Agent Node runs (owner =
|
||||
workflow_run) and to Agent App conversations (owner = conversation).
|
||||
"""
|
||||
|
||||
# Snapshot can be reused by a later Agent run in the same session.
|
||||
ACTIVE = "active"
|
||||
# Snapshot has been retired and must not be submitted to Agent backend again.
|
||||
CLEANED = "cleaned"
|
||||
|
||||
|
||||
class AgentRuntimeSessionOwnerType(StrEnum):
|
||||
"""Which product surface owns an Agent runtime session row."""
|
||||
|
||||
# Owned by one workflow Agent Node execution scope.
|
||||
WORKFLOW_RUN = "workflow_run"
|
||||
# Owned by one Agent App conversation (multi-turn chat).
|
||||
CONVERSATION = "conversation"
|
||||
|
||||
|
||||
# Back-compat alias: the workflow lifecycle code (shipped in PR #36724) imports
|
||||
# the old name. Kept so unifying the table does not churn that path.
|
||||
WorkflowAgentRuntimeSessionStatus = AgentRuntimeSessionStatus
|
||||
|
||||
|
||||
class Agent(DefaultFieldsMixin, Base):
|
||||
"""Workspace-scoped Agent identity used by Agent Roster and workflow-only agents."""
|
||||
|
||||
@ -284,54 +302,90 @@ class WorkflowAgentNodeBinding(DefaultFieldsMixin, Base):
|
||||
return dict(self.node_job_config)
|
||||
|
||||
|
||||
class WorkflowAgentRuntimeSession(DefaultFieldsMixin, Base):
|
||||
"""Persisted Agent backend session snapshot for one workflow Agent node execution scope.
|
||||
class AgentRuntimeSession(DefaultFieldsMixin, Base):
|
||||
"""Persisted Agent backend session snapshot, owner-agnostic.
|
||||
|
||||
The snapshot is runtime state returned by Agent backend. It is intentionally
|
||||
separate from Agent Soul snapshots and workflow node-job config.
|
||||
One unified table serves both owners (decision Q2):
|
||||
- workflow Agent Node runs: ``owner_type = workflow_run``; the
|
||||
``workflow_id / workflow_run_id / node_id / binding_id /
|
||||
agent_config_snapshot_id / composition_layer_specs`` columns are set.
|
||||
- Agent App conversations: ``owner_type = conversation``; the
|
||||
``conversation_id`` and ``agent_config_snapshot_id`` columns are set and
|
||||
the workflow columns stay NULL.
|
||||
|
||||
The snapshot is runtime state returned by Agent backend, kept separate from
|
||||
Agent Soul snapshots and workflow node-job config.
|
||||
"""
|
||||
|
||||
__tablename__ = "workflow_agent_runtime_sessions"
|
||||
__tablename__ = "agent_runtime_sessions"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="workflow_agent_runtime_session_pkey"),
|
||||
UniqueConstraint(
|
||||
sa.PrimaryKeyConstraint("id", name="agent_runtime_session_pkey"),
|
||||
# Workflow owner uniqueness (partial: only rows with a workflow_run_id).
|
||||
Index(
|
||||
"agent_runtime_session_workflow_scope_unique",
|
||||
"tenant_id",
|
||||
"workflow_run_id",
|
||||
"node_id",
|
||||
"binding_id",
|
||||
"agent_id",
|
||||
name="workflow_agent_runtime_session_scope_unique",
|
||||
unique=True,
|
||||
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
|
||||
),
|
||||
# Conversation owner uniqueness (partial: only rows with a conversation_id).
|
||||
Index(
|
||||
"agent_runtime_session_conversation_scope_unique",
|
||||
"tenant_id",
|
||||
"conversation_id",
|
||||
"agent_id",
|
||||
"agent_config_snapshot_id",
|
||||
unique=True,
|
||||
postgresql_where=sa.text("conversation_id IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"workflow_agent_runtime_session_lookup_idx",
|
||||
"agent_runtime_session_workflow_lookup_idx",
|
||||
"tenant_id",
|
||||
"workflow_run_id",
|
||||
"node_id",
|
||||
"status",
|
||||
),
|
||||
Index("workflow_agent_runtime_session_backend_run_idx", "backend_run_id"),
|
||||
Index(
|
||||
"agent_runtime_session_conversation_lookup_idx",
|
||||
"tenant_id",
|
||||
"conversation_id",
|
||||
"status",
|
||||
),
|
||||
Index("agent_runtime_session_backend_run_idx", "backend_run_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)
|
||||
workflow_run_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
node_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
binding_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
owner_type: Mapped[AgentRuntimeSessionOwnerType] = mapped_column(
|
||||
EnumText(AgentRuntimeSessionOwnerType, length=32), nullable=False
|
||||
)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_config_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
backend_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
session_snapshot: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
# JSON-encoded list of ``WorkflowAgentSessionLayerSpec`` ({name, type, deps,
|
||||
# config}). Drives Agent backend cleanup-only runs: the agenton compositor
|
||||
# rejects a session snapshot whose layer names do not match the cleanup
|
||||
# composition, so we must replay the same layer graph (minus credential-
|
||||
# bearing plugin layers) when issuing the cleanup request.
|
||||
# Workflow-owner columns (NULL for conversation owner).
|
||||
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
agent_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
# JSON-encoded list of cleanup layer specs ({name, type, deps, config}).
|
||||
# Drives Agent backend cleanup-only runs: the agenton compositor rejects a
|
||||
# session snapshot whose layer names do not match the cleanup composition,
|
||||
# so we replay the same layer graph (minus credential-bearing plugin layers).
|
||||
composition_layer_specs: Mapped[str] = mapped_column(LongText, nullable=False, server_default="[]")
|
||||
status: Mapped[WorkflowAgentRuntimeSessionStatus] = mapped_column(
|
||||
EnumText(WorkflowAgentRuntimeSessionStatus, length=32),
|
||||
# Conversation-owner column (NULL for workflow owner).
|
||||
conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
status: Mapped[AgentRuntimeSessionStatus] = mapped_column(
|
||||
EnumText(AgentRuntimeSessionStatus, length=32),
|
||||
nullable=False,
|
||||
default=WorkflowAgentRuntimeSessionStatus.ACTIVE,
|
||||
default=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
cleaned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
# Back-compat alias for the shipped workflow lifecycle code (PR #36724).
|
||||
WorkflowAgentRuntimeSession = AgentRuntimeSession
|
||||
|
||||
@ -366,6 +366,10 @@ class AppMode(StrEnum):
|
||||
CHAT = "chat"
|
||||
ADVANCED_CHAT = "advanced-chat"
|
||||
AGENT_CHAT = "agent-chat"
|
||||
# New Agent App type backed by the Dify Agent runtime (distinct from the
|
||||
# legacy ``agent-chat`` ReAct app). The app is bound 1:1 to a roster Agent
|
||||
# via ``Agent.app_id``; its configuration lives in the Agent Soul snapshot.
|
||||
AGENT = "agent"
|
||||
CHANNEL = "channel"
|
||||
RAG_PIPELINE = "rag-pipeline"
|
||||
|
||||
@ -458,6 +462,27 @@ class App(Base):
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def bound_agent_id(self) -> str | None:
|
||||
"""For an Agent App (mode=agent), the roster Agent it is backed by.
|
||||
|
||||
Resolved via ``Agent.app_id`` so the console can open the Composer in
|
||||
roster-detail mode from the app id. ``None`` for non-agent apps.
|
||||
"""
|
||||
if self.mode != AppMode.AGENT:
|
||||
return None
|
||||
from .agent import Agent, AgentScope, AgentSource, AgentStatus
|
||||
|
||||
agent = db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.app_id == self.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
return agent.id if agent else None
|
||||
|
||||
@property
|
||||
def api_base_url(self) -> str:
|
||||
base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/")
|
||||
|
||||
@ -1061,6 +1061,48 @@ Run draft workflow for advanced chat application
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent app composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
|
||||
|
||||
### /apps/{app_id}/agent-features
|
||||
|
||||
#### POST
|
||||
##### Description
|
||||
|
||||
Update an Agent App's presentation features (opener, follow-up, citations, ...)
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| payload | body | | Yes | [AgentAppFeaturesRequest](#agentappfeaturesrequest) |
|
||||
| app_id | path | Application ID | Yes | string |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Features updated successfully | [SimpleResultResponse](#simpleresultresponse) |
|
||||
| 400 | Invalid configuration | |
|
||||
| 404 | App not found | |
|
||||
|
||||
### /apps/{app_id}/agent-referencing-workflows
|
||||
|
||||
#### GET
|
||||
##### Description
|
||||
|
||||
List workflow apps that reference this Agent App's bound Agent (read-only)
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Referencing workflows listed successfully | [AgentReferencingWorkflowsResponse](#agentreferencingworkflowsresponse) |
|
||||
| 404 | App not found | |
|
||||
|
||||
### /apps/{app_id}/agent/logs
|
||||
|
||||
#### GET
|
||||
@ -10726,6 +10768,23 @@ Get banner list
|
||||
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
|
||||
| variant | string | | Yes |
|
||||
|
||||
#### AgentAppFeaturesRequest
|
||||
|
||||
Presentation features configurable on an Agent App.
|
||||
|
||||
All fields are optional; an omitted field is reset to its disabled/empty
|
||||
default (the config form sends the full desired feature state on save).
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| opening_statement | string | Conversation opener shown before the first turn | No |
|
||||
| retriever_resource | object | Citations / attributions config, e.g. {'enabled': true} | No |
|
||||
| sensitive_word_avoidance | object | Content moderation config | No |
|
||||
| speech_to_text | object | Speech-to-text config | No |
|
||||
| suggested_questions | [ string ] | Preset questions shown alongside the opener | No |
|
||||
| suggested_questions_after_answer | object | Follow-up suggestions config, e.g. {'enabled': true} | No |
|
||||
| text_to_speech | object | Text-to-speech config | No |
|
||||
|
||||
#### AgentComposerAgentResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -10946,6 +11005,22 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| conversation_id | string | Conversation UUID | Yes |
|
||||
| message_id | string | Message UUID | Yes |
|
||||
|
||||
#### AgentReferencingWorkflowResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| app_id | string | | Yes |
|
||||
| app_mode | string | | Yes |
|
||||
| app_name | string | | Yes |
|
||||
| node_ids | [ string ] | | No |
|
||||
| workflow_id | string | | Yes |
|
||||
|
||||
#### AgentReferencingWorkflowsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [AgentReferencingWorkflowResponse](#agentreferencingworkflowresponse) ] | | No |
|
||||
|
||||
#### AgentRosterListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -11392,6 +11467,7 @@ Enum class for api provider schema type.
|
||||
| access_mode | string | | No |
|
||||
| api_base_url | string | | No |
|
||||
| app_model_config | [ModelConfig](#modelconfig) | | No |
|
||||
| bound_agent_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| deleted_tools | [ [DeletedTool](#deletedtool) ] | | No |
|
||||
@ -11460,7 +11536,7 @@ Enum class for api provider schema type.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| is_created_by_me | boolean | Filter by creator | No |
|
||||
| limit | integer | Page size (1-100) | No |
|
||||
| mode | string | App mode filter<br>*Enum:* `"advanced-chat"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No |
|
||||
| mode | string | App mode filter<br>*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No |
|
||||
| name | string | Filter by app name | No |
|
||||
| page | integer | Page number (1-99999) | No |
|
||||
| tag_ids | [ string ] | Filter by tag IDs | No |
|
||||
@ -11729,7 +11805,7 @@ Button styles for user actions.
|
||||
| conversation_id | string | Conversation ID | No |
|
||||
| files | [ ] | Uploaded files | No |
|
||||
| inputs | object | | Yes |
|
||||
| model_config | object | | Yes |
|
||||
| model_config | object | | No |
|
||||
| parent_message_id | string | Parent message ID | No |
|
||||
| query | string | User query | Yes |
|
||||
| response_mode | string | Response mode<br>*Enum:* `"blocking"`, `"streaming"` | No |
|
||||
@ -11868,7 +11944,7 @@ Button styles for user actions.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ ] | Uploaded files | No |
|
||||
| inputs | object | | Yes |
|
||||
| model_config | object | | Yes |
|
||||
| model_config | object | | No |
|
||||
| query | string | Query text | No |
|
||||
| response_mode | string | Response mode<br>*Enum:* `"blocking"`, `"streaming"` | No |
|
||||
| retriever_from | string | Retriever source | No |
|
||||
@ -12156,7 +12232,7 @@ Condition detail
|
||||
| icon | string | Icon | No |
|
||||
| icon_background | string | Icon background color | No |
|
||||
| icon_type | [IconType](#icontype) | Icon type | No |
|
||||
| mode | string | App mode<br>*Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes |
|
||||
| mode | string | App mode<br>*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes |
|
||||
| name | string | App name | Yes |
|
||||
|
||||
#### CredentialType
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@ -13,8 +13,11 @@ from models.agent import (
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App
|
||||
from models.workflow import Workflow
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.errors import (
|
||||
@ -26,6 +29,16 @@ from services.agent.errors import (
|
||||
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload
|
||||
|
||||
|
||||
class AgentReferencingWorkflow(TypedDict):
|
||||
"""A workflow app that references a roster Agent via an Agent node."""
|
||||
|
||||
app_id: str
|
||||
app_name: str
|
||||
app_mode: str
|
||||
workflow_id: str
|
||||
node_ids: list[str]
|
||||
|
||||
|
||||
class AgentRosterService:
|
||||
def __init__(self, session: Any):
|
||||
self._session = session
|
||||
@ -203,6 +216,131 @@ class AgentRosterService:
|
||||
raise AgentNameConflictError() from exc
|
||||
return agent
|
||||
|
||||
def create_backing_agent_for_app(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
app_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
icon_type: Any = None,
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
) -> Agent:
|
||||
"""Create the roster Agent that backs an Agent App, linked via ``app_id``.
|
||||
|
||||
Unlike :meth:`create_roster_agent`, this does not commit: the caller
|
||||
(``AppService.create_app``) owns the surrounding transaction so the App
|
||||
row and its backing Agent are persisted atomically. A default (empty)
|
||||
Agent Soul is seeded; the user configures model/prompt/tools afterward in
|
||||
the Composer.
|
||||
"""
|
||||
agent = Agent(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
description=description,
|
||||
icon_type=icon_type,
|
||||
icon=icon,
|
||||
icon_background=icon_background,
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id=app_id,
|
||||
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=AgentSoulConfig(),
|
||||
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,
|
||||
created_by=account_id,
|
||||
)
|
||||
self._session.add(revision)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
self._session.flush()
|
||||
return agent
|
||||
|
||||
def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None:
|
||||
"""Return the roster Agent that backs the given Agent App, if any."""
|
||||
return self._session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
|
||||
def list_workflows_referencing_app_agent(self, *, tenant_id: str, app_id: str) -> list[AgentReferencingWorkflow]:
|
||||
"""List the workflow apps that reference this Agent App's bound Agent.
|
||||
|
||||
Read-only "Workflow access" surface: an Agent App is backed by a roster
|
||||
Agent, and workflow Agent nodes may bind that same roster Agent. This
|
||||
returns the distinct workflow apps (with the referencing node ids) so the
|
||||
console can show "used by" without exposing the workflow internals.
|
||||
"""
|
||||
agent = self.get_app_backing_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
if agent is None:
|
||||
return []
|
||||
|
||||
bindings = self._session.scalars(
|
||||
select(WorkflowAgentNodeBinding).where(
|
||||
WorkflowAgentNodeBinding.tenant_id == tenant_id,
|
||||
WorkflowAgentNodeBinding.agent_id == agent.id,
|
||||
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT,
|
||||
)
|
||||
).all()
|
||||
if not bindings:
|
||||
return []
|
||||
|
||||
# Collapse the per-version / per-node rows into one entry per workflow app.
|
||||
node_ids_by_workflow: dict[tuple[str, str], set[str]] = {}
|
||||
for binding in bindings:
|
||||
node_ids_by_workflow.setdefault((binding.app_id, binding.workflow_id), set()).add(binding.node_id)
|
||||
|
||||
referenced_app_ids = {workflow_app_id for workflow_app_id, _ in node_ids_by_workflow}
|
||||
apps = {app.id: app for app in self._session.scalars(select(App).where(App.id.in_(referenced_app_ids))).all()}
|
||||
|
||||
result: list[AgentReferencingWorkflow] = []
|
||||
for (workflow_app_id, workflow_id), node_ids in node_ids_by_workflow.items():
|
||||
app = apps.get(workflow_app_id)
|
||||
if app is None:
|
||||
# Orphaned binding (workflow app deleted): skip rather than 500.
|
||||
continue
|
||||
result.append(
|
||||
AgentReferencingWorkflow(
|
||||
app_id=workflow_app_id,
|
||||
app_name=app.name,
|
||||
app_mode=str(app.mode),
|
||||
workflow_id=workflow_id,
|
||||
node_ids=sorted(node_ids),
|
||||
)
|
||||
)
|
||||
result.sort(key=lambda item: item["app_name"].lower())
|
||||
return result
|
||||
|
||||
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(
|
||||
|
||||
96
api/services/agent_app_feature_service.py
Normal file
96
api/services/agent_app_feature_service.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""Validate and persist the app-level presentation features of an Agent App.
|
||||
|
||||
An Agent App keeps its model / prompt / tools in the bound Agent Soul; only the
|
||||
PRD "Misc Legacy" presentation features — conversation opener, follow-up
|
||||
suggestions, citations, content moderation and speech — live on
|
||||
``app_model_config``. This service validates that feature subset and writes a
|
||||
new ``app_model_config`` version, mirroring the legacy model-config save flow
|
||||
but deliberately never touching model, prompt, tools, datasets or agent_mode
|
||||
(those are owned by the Soul and must not be settable through this endpoint).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from core.app.app_config.common.sensitive_word_avoidance.manager import SensitiveWordAvoidanceConfigManager
|
||||
from core.app.app_config.features.opening_statement.manager import OpeningStatementConfigManager
|
||||
from core.app.app_config.features.retrieval_resource.manager import RetrievalResourceConfigManager
|
||||
from core.app.app_config.features.speech_to_text.manager import SpeechToTextConfigManager
|
||||
from core.app.app_config.features.suggested_questions_after_answer.manager import (
|
||||
SuggestedQuestionsAfterAnswerConfigManager,
|
||||
)
|
||||
from core.app.app_config.features.text_to_speech.manager import TextToSpeechConfigManager
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.account import Account
|
||||
from models.model import App, AppModelConfig, AppModelConfigDict
|
||||
|
||||
|
||||
class AgentAppFeatureConfigService:
|
||||
"""Service for the Agent App presentation-feature config surface."""
|
||||
|
||||
# The only keys this surface accepts. Anything else (model, pre_prompt,
|
||||
# agent_mode, tools, datasets, user_input_form, ...) is dropped so a caller
|
||||
# cannot smuggle Soul-owned configuration in through the feature endpoint.
|
||||
ALLOWED_KEYS = (
|
||||
"opening_statement",
|
||||
"suggested_questions",
|
||||
"suggested_questions_after_answer",
|
||||
"speech_to_text",
|
||||
"text_to_speech",
|
||||
"retriever_resource",
|
||||
"sensitive_word_avoidance",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_features(cls, tenant_id: str, config: dict[str, Any]) -> AppModelConfigDict:
|
||||
"""Validate and normalize the feature subset, filling defaults."""
|
||||
working = {key: config[key] for key in cls.ALLOWED_KEYS if key in config}
|
||||
|
||||
related_keys: list[str] = []
|
||||
for validate in (
|
||||
OpeningStatementConfigManager.validate_and_set_defaults,
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults,
|
||||
SpeechToTextConfigManager.validate_and_set_defaults,
|
||||
TextToSpeechConfigManager.validate_and_set_defaults,
|
||||
RetrievalResourceConfigManager.validate_and_set_defaults,
|
||||
):
|
||||
working, keys = validate(working)
|
||||
related_keys.extend(keys)
|
||||
|
||||
# Moderation needs the tenant to validate its provider configuration.
|
||||
working, keys = SensitiveWordAvoidanceConfigManager.validate_and_set_defaults(tenant_id, working)
|
||||
related_keys.extend(keys)
|
||||
|
||||
filtered = {key: working.get(key) for key in set(related_keys)}
|
||||
return cast(AppModelConfigDict, filtered)
|
||||
|
||||
@classmethod
|
||||
def update_features(cls, *, app_model: App, account: Account, config: dict[str, Any]) -> AppModelConfig:
|
||||
"""Persist the presentation features as a new app_model_config version.
|
||||
|
||||
Returns the new ``AppModelConfig`` row (now referenced by the app); the
|
||||
row carries only feature flags, with model / prompt / agent_mode left
|
||||
``NULL`` so the Agent Soul remains the single source of truth for those.
|
||||
"""
|
||||
validated = cls.validate_features(app_model.tenant_id, config)
|
||||
|
||||
new_config = AppModelConfig(
|
||||
app_id=app_model.id,
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
).from_model_config_dict(validated)
|
||||
|
||||
db.session.add(new_config)
|
||||
db.session.flush()
|
||||
|
||||
app_model.app_model_config_id = new_config.id
|
||||
app_model.updated_by = account.id
|
||||
app_model.updated_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
|
||||
return new_config
|
||||
|
||||
|
||||
__all__ = ["AgentAppFeatureConfigService"]
|
||||
@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
|
||||
from core.app.apps.agent_app.app_generator import AgentAppGenerator
|
||||
from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator
|
||||
from core.app.apps.chat.app_generator import ChatAppGenerator
|
||||
from core.app.apps.completion.app_generator import CompletionAppGenerator
|
||||
@ -140,6 +141,15 @@ class AppGenerateService:
|
||||
),
|
||||
request_id,
|
||||
)
|
||||
case AppMode.AGENT:
|
||||
return rate_limit.generate(
|
||||
AgentAppGenerator.convert_to_event_stream(
|
||||
AgentAppGenerator().generate(
|
||||
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
|
||||
),
|
||||
),
|
||||
request_id,
|
||||
)
|
||||
case AppMode.CHAT:
|
||||
return rate_limit.generate(
|
||||
ChatAppGenerator.convert_to_event_stream(
|
||||
|
||||
@ -16,5 +16,7 @@ class AppModelConfigService:
|
||||
return AgentChatAppConfigManager.config_validate(tenant_id, config)
|
||||
case AppMode.COMPLETION:
|
||||
return CompletionAppConfigManager.config_validate(tenant_id, config)
|
||||
case AppMode.WORKFLOW | AppMode.ADVANCED_CHAT | AppMode.CHANNEL | AppMode.RAG_PIPELINE:
|
||||
case AppMode.WORKFLOW | AppMode.ADVANCED_CHAT | AppMode.CHANNEL | AppMode.RAG_PIPELINE | AppMode.AGENT:
|
||||
# Agent App presentation features go through AgentAppFeatureConfigService,
|
||||
# not this legacy EasyUI model-config validator.
|
||||
raise ValueError(f"Invalid app mode: {app_mode}")
|
||||
|
||||
@ -23,6 +23,7 @@ from graphon.model_runtime.model_providers.base.large_language_model import Larg
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.login import current_user
|
||||
from models import Account
|
||||
from models.agent import AgentIconType
|
||||
from models.model import App, AppMode, AppModelConfig, IconType, Site
|
||||
from models.tools import ApiToolProvider
|
||||
from services.billing_service import BillingService
|
||||
@ -38,7 +39,7 @@ logger = logging.getLogger(__name__)
|
||||
class AppListParams(BaseModel):
|
||||
page: int = Field(default=1, ge=1)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "channel", "all"] = "all"
|
||||
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = "all"
|
||||
name: str | None = None
|
||||
tag_ids: list[str] | None = None
|
||||
is_created_by_me: bool | None = None
|
||||
@ -49,7 +50,7 @@ class AppListParams(BaseModel):
|
||||
class CreateAppParams(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
description: str | None = None
|
||||
mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
|
||||
mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
|
||||
icon_type: str | None = None
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
@ -124,6 +125,8 @@ class AppService:
|
||||
filters.append(App.mode == AppMode.ADVANCED_CHAT)
|
||||
elif params.mode == "agent-chat":
|
||||
filters.append(App.mode == AppMode.AGENT_CHAT)
|
||||
elif params.mode == "agent":
|
||||
filters.append(App.mode == AppMode.AGENT)
|
||||
|
||||
if params.status:
|
||||
filters.append(App.status == params.status)
|
||||
@ -246,6 +249,39 @@ class AppService:
|
||||
db.session.flush()
|
||||
|
||||
app.app_model_config_id = app_model_config.id
|
||||
elif app_mode == AppMode.AGENT:
|
||||
# An Agent App keeps its model / prompt / tools in the bound Agent
|
||||
# Soul, so the app_model_config row carries no model — only the
|
||||
# app-level presentation features the PRD requires (conversation
|
||||
# opener, follow-up suggestions, citations, moderation, annotation).
|
||||
# They default to disabled/empty here and are read by both the
|
||||
# webapp /parameters endpoint and the chat pipeline. agent_mode is
|
||||
# left unset so App.is_agent stays False (this is the new Agent App
|
||||
# type, not a legacy function-call/react agent).
|
||||
agent_app_model_config = AppModelConfig(app_id=app.id, created_by=account.id, updated_by=account.id)
|
||||
db.session.add(agent_app_model_config)
|
||||
db.session.flush()
|
||||
|
||||
app.app_model_config_id = agent_app_model_config.id
|
||||
|
||||
# Agent App type is backed 1:1 by a roster Agent (linked via Agent.app_id).
|
||||
# Created in the same transaction so the App and its backing Agent persist
|
||||
# atomically; the Agent Soul (model/prompt/tools) is configured afterward
|
||||
# in the Composer.
|
||||
if app_mode == AppMode.AGENT:
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
|
||||
icon_type = AgentIconType(params.icon_type) if params.icon_type else None
|
||||
AgentRosterService(db.session).create_backing_agent_for_app(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
app_id=app.id,
|
||||
name=params.name,
|
||||
description=params.description or "",
|
||||
icon_type=icon_type,
|
||||
icon=params.icon,
|
||||
icon_background=params.icon_background,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
"""Regression tests for CreateAppPayload mode validation.
|
||||
|
||||
The HTTP create-app payload must accept the new "agent" app mode; without it a
|
||||
user cannot create an Agent App through POST /console/api/apps even though the
|
||||
service layer (CreateAppParams) supports it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.console.app.app import CreateAppPayload
|
||||
|
||||
|
||||
class TestCreateAppPayloadMode:
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"],
|
||||
)
|
||||
def test_accepts_supported_modes(self, mode: str):
|
||||
payload = CreateAppPayload.model_validate({"name": "X", "mode": mode})
|
||||
assert payload.mode == mode
|
||||
|
||||
def test_rejects_unknown_mode(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CreateAppPayload.model_validate({"name": "X", "mode": "not-a-mode"})
|
||||
@ -211,12 +211,12 @@ class TestAppModeValidation:
|
||||
|
||||
def test_chat_modes_are_distinct_from_completion(self):
|
||||
"""Test that chat modes are distinct from completion mode."""
|
||||
chat_modes = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}
|
||||
chat_modes = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}
|
||||
assert AppMode.COMPLETION not in chat_modes
|
||||
|
||||
def test_workflow_mode_is_distinct_from_chat_modes(self):
|
||||
"""Test that WORKFLOW mode is not a chat mode."""
|
||||
chat_modes = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}
|
||||
chat_modes = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}
|
||||
assert AppMode.WORKFLOW not in chat_modes
|
||||
|
||||
def test_not_chat_app_error_can_be_raised(self):
|
||||
@ -226,7 +226,16 @@ class TestAppModeValidation:
|
||||
|
||||
def test_all_app_modes_are_defined(self):
|
||||
"""Test that all expected app modes are defined."""
|
||||
expected_modes = ["COMPLETION", "CHAT", "AGENT_CHAT", "ADVANCED_CHAT", "WORKFLOW", "CHANNEL", "RAG_PIPELINE"]
|
||||
expected_modes = [
|
||||
"COMPLETION",
|
||||
"CHAT",
|
||||
"AGENT_CHAT",
|
||||
"AGENT",
|
||||
"ADVANCED_CHAT",
|
||||
"WORKFLOW",
|
||||
"CHANNEL",
|
||||
"RAG_PIPELINE",
|
||||
]
|
||||
for mode_name in expected_modes:
|
||||
assert hasattr(AppMode, mode_name), f"AppMode.{mode_name} should exist"
|
||||
|
||||
|
||||
@ -342,13 +342,14 @@ class TestConversationAppModeValidation:
|
||||
[
|
||||
AppMode.CHAT,
|
||||
AppMode.AGENT_CHAT.value,
|
||||
AppMode.AGENT.value,
|
||||
AppMode.ADVANCED_CHAT.value,
|
||||
],
|
||||
)
|
||||
def test_chat_modes_are_valid_for_conversation_endpoints(self, mode):
|
||||
"""Test that all chat modes are valid for conversation endpoints.
|
||||
|
||||
Verifies that CHAT, AGENT_CHAT, and ADVANCED_CHAT modes pass
|
||||
Verifies that CHAT, AGENT_CHAT, AGENT, and ADVANCED_CHAT modes pass
|
||||
validation without raising NotChatAppError.
|
||||
"""
|
||||
app = Mock(spec=App)
|
||||
@ -356,7 +357,7 @@ class TestConversationAppModeValidation:
|
||||
|
||||
# Validation should pass without raising for chat modes
|
||||
app_mode = AppMode.value_of(app.mode)
|
||||
assert app_mode in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}
|
||||
assert app_mode in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}
|
||||
|
||||
def test_completion_mode_is_invalid_for_conversation_endpoints(self):
|
||||
"""Test that COMPLETION mode is invalid for conversation endpoints.
|
||||
@ -368,7 +369,7 @@ class TestConversationAppModeValidation:
|
||||
app.mode = AppMode.COMPLETION
|
||||
|
||||
app_mode = AppMode.value_of(app.mode)
|
||||
assert app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}
|
||||
assert app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}
|
||||
with pytest.raises(NotChatAppError):
|
||||
raise NotChatAppError()
|
||||
|
||||
@ -382,7 +383,7 @@ class TestConversationAppModeValidation:
|
||||
app.mode = AppMode.WORKFLOW.value
|
||||
|
||||
app_mode = AppMode.value_of(app.mode)
|
||||
assert app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}
|
||||
assert app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}
|
||||
with pytest.raises(NotChatAppError):
|
||||
raise NotChatAppError()
|
||||
|
||||
|
||||
@ -194,18 +194,18 @@ class TestMessageAppModeValidation:
|
||||
|
||||
def test_chat_modes_are_valid_for_message_endpoints(self):
|
||||
"""Test that all chat modes are valid."""
|
||||
valid_modes = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}
|
||||
valid_modes = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}
|
||||
for mode in valid_modes:
|
||||
assert mode in valid_modes
|
||||
|
||||
def test_completion_mode_is_invalid_for_message_endpoints(self):
|
||||
"""Test that COMPLETION mode is invalid."""
|
||||
chat_modes = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}
|
||||
chat_modes = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}
|
||||
assert AppMode.COMPLETION not in chat_modes
|
||||
|
||||
def test_workflow_mode_is_invalid_for_message_endpoints(self):
|
||||
"""Test that WORKFLOW mode is invalid."""
|
||||
chat_modes = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}
|
||||
chat_modes = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}
|
||||
assert AppMode.WORKFLOW not in chat_modes
|
||||
|
||||
def test_not_chat_app_error_can_be_raised(self):
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
"""Unit tests for AgentAppConfigManager._synthesize_config_dict — the Soul →
|
||||
app_model_config-shaped dict bridge that lets an Agent App ride the chat pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def _soul() -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"model": {
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "langgenius/openai/openai",
|
||||
"model": "gpt-4o-mini",
|
||||
"model_settings": {"temperature": 0.2},
|
||||
},
|
||||
"prompt": {"system_prompt": "You are Iris."},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_model_and_prompt_come_from_soul():
|
||||
d = AgentAppConfigManager._synthesize_config_dict(_soul(), None)
|
||||
assert d["model"] == {
|
||||
"provider": "langgenius/openai/openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
}
|
||||
assert d["pre_prompt"] == "You are Iris."
|
||||
assert d["user_input_form"] == []
|
||||
|
||||
|
||||
def test_feature_flags_come_from_app_model_config_when_present():
|
||||
# Q3: opener/follow-up/etc. live on app_model_config; model/prompt stay from Soul.
|
||||
fake_amc = SimpleNamespace(
|
||||
to_dict=lambda: {
|
||||
"opening_statement": "Hi, I'm Iris.",
|
||||
"suggested_questions_after_answer": {"enabled": True},
|
||||
"model": {"provider": "should-be-overridden", "name": "old"},
|
||||
"pre_prompt": "old prompt",
|
||||
}
|
||||
)
|
||||
d = AgentAppConfigManager._synthesize_config_dict(_soul(), fake_amc) # type: ignore[arg-type]
|
||||
# feature flags preserved
|
||||
assert d["opening_statement"] == "Hi, I'm Iris."
|
||||
assert d["suggested_questions_after_answer"] == {"enabled": True}
|
||||
# model + prompt overridden by Soul (single source of truth)
|
||||
assert d["model"]["name"] == "gpt-4o-mini"
|
||||
assert d["pre_prompt"] == "You are Iris."
|
||||
|
||||
|
||||
def test_missing_soul_model_leaves_no_model_key():
|
||||
d = AgentAppConfigManager._synthesize_config_dict(AgentSoulConfig(), None)
|
||||
assert "model" not in d
|
||||
assert d["pre_prompt"] == ""
|
||||
|
||||
|
||||
def test_prompt_type_defaults_to_simple():
|
||||
# PromptTemplateConfigManager.convert requires prompt_type; an Agent App with
|
||||
# no legacy app_model_config must still get the "simple" slot synthesized.
|
||||
d = AgentAppConfigManager._synthesize_config_dict(_soul(), None)
|
||||
assert d["prompt_type"] == "simple"
|
||||
|
||||
|
||||
def test_get_app_config_has_null_model_config_id_without_legacy_row():
|
||||
# An Agent App has no app_model_config row; the conversation's
|
||||
# app_model_config_id (a UUID column) must be NULL, not "".
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="11111111-1111-1111-1111-111111111111",
|
||||
id="22222222-2222-2222-2222-222222222222",
|
||||
mode="agent",
|
||||
)
|
||||
app_config = AgentAppConfigManager.get_app_config(
|
||||
app_model=app_model, # type: ignore[arg-type]
|
||||
agent_soul=_soul(),
|
||||
app_model_config=None,
|
||||
conversation=None,
|
||||
)
|
||||
assert app_config.app_model_config_id is None
|
||||
@ -0,0 +1,204 @@
|
||||
"""Unit tests for AgentAppGenerator.generate() and its worker thread.
|
||||
|
||||
Mirrors the agent_chat generator tests: every collaborator (config manager,
|
||||
model converter, queue manager, thread, response converter, the agent backend
|
||||
client stack) is patched at the module level, the generate entity class is
|
||||
patched so no real pydantic entity is built, and the worker's flask-context
|
||||
manager is replaced with a no-op so the thread body can run inline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
|
||||
MODULE = "core.app.apps.agent_app.app_generator"
|
||||
|
||||
|
||||
class DummyAccount:
|
||||
def __init__(self, user_id: str) -> None:
|
||||
self.id = user_id
|
||||
self.session_id = f"session-{user_id}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def generator(mocker: MockerFixture) -> AgentAppGenerator:
|
||||
gen = AgentAppGenerator()
|
||||
mocker.patch(f"{MODULE}.current_app", new=mocker.MagicMock(_get_current_object=mocker.MagicMock()))
|
||||
mocker.patch(f"{MODULE}.contextvars.copy_context", return_value="ctx")
|
||||
return gen
|
||||
|
||||
|
||||
class TestGenerateGuards:
|
||||
def test_rejects_blocking_mode(self, generator, mocker: MockerFixture):
|
||||
with pytest.raises(AgentAppGeneratorError, match="only supports streaming"):
|
||||
generator.generate(
|
||||
app_model=mocker.MagicMock(),
|
||||
user=DummyAccount("u"),
|
||||
args={},
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
streaming=False,
|
||||
)
|
||||
|
||||
def test_requires_query(self, generator, mocker: MockerFixture):
|
||||
with pytest.raises(AgentAppGeneratorError, match="query is required"):
|
||||
generator.generate(
|
||||
app_model=mocker.MagicMock(),
|
||||
user=DummyAccount("u"),
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
def test_rejects_blank_query(self, generator, mocker: MockerFixture):
|
||||
with pytest.raises(AgentAppGeneratorError, match="query is required"):
|
||||
generator.generate(
|
||||
app_model=mocker.MagicMock(),
|
||||
user=DummyAccount("u"),
|
||||
args={"query": " ", "inputs": {}},
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateSuccess:
|
||||
def test_generate_orchestrates_and_starts_worker(self, generator, mocker: MockerFixture):
|
||||
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
|
||||
user = DummyAccount("user")
|
||||
|
||||
generator._resolve_agent = mocker.MagicMock(
|
||||
return_value=(mocker.MagicMock(id="agent1"), mocker.MagicMock(id="snap1"), mocker.MagicMock())
|
||||
)
|
||||
generator._prepare_user_inputs = mocker.MagicMock(return_value={"x": 1})
|
||||
generator._init_generate_records = mocker.MagicMock(
|
||||
return_value=(mocker.MagicMock(id="conv", mode="agent"), mocker.MagicMock(id="msg"))
|
||||
)
|
||||
generator._handle_response = mocker.MagicMock(return_value="raw-response")
|
||||
|
||||
mocker.patch(
|
||||
f"{MODULE}.AgentAppConfigManager.get_app_config",
|
||||
return_value=mocker.MagicMock(variables=[], tenant_id="tenant", app_id="app1"),
|
||||
)
|
||||
mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock(model="gpt-4o-mini"))
|
||||
mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user"))
|
||||
mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock())
|
||||
thread_obj = mocker.MagicMock()
|
||||
mocker.patch(f"{MODULE}.threading.Thread", return_value=thread_obj)
|
||||
mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert", return_value={"result": "ok"})
|
||||
|
||||
result = generator.generate(
|
||||
app_model=app_model,
|
||||
user=user,
|
||||
args={"query": "hello", "inputs": {"name": "world"}},
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
assert result == {"result": "ok"}
|
||||
thread_obj.start.assert_called_once()
|
||||
generator._resolve_agent.assert_called_once_with(app_model)
|
||||
|
||||
def test_generate_loads_existing_conversation(self, generator, mocker: MockerFixture):
|
||||
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
|
||||
generator._resolve_agent = mocker.MagicMock(
|
||||
return_value=(mocker.MagicMock(id="a"), mocker.MagicMock(id="s"), mocker.MagicMock())
|
||||
)
|
||||
generator._prepare_user_inputs = mocker.MagicMock(return_value={})
|
||||
generator._init_generate_records = mocker.MagicMock(
|
||||
return_value=(mocker.MagicMock(id="conv", mode="agent"), mocker.MagicMock(id="msg"))
|
||||
)
|
||||
generator._handle_response = mocker.MagicMock(return_value="raw")
|
||||
get_conv = mocker.patch(
|
||||
f"{MODULE}.ConversationService.get_conversation", return_value=mocker.MagicMock(id="conv")
|
||||
)
|
||||
mocker.patch(f"{MODULE}.AgentAppConfigManager.get_app_config", return_value=mocker.MagicMock(variables=[]))
|
||||
mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert", return_value={"result": "ok"})
|
||||
|
||||
generator.generate(
|
||||
app_model=app_model,
|
||||
user=DummyAccount("user"),
|
||||
args={"query": "hi", "inputs": {}, "conversation_id": "conv"},
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
get_conv.assert_called_once()
|
||||
|
||||
|
||||
class TestGenerateWorker:
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_context(self, mocker: MockerFixture):
|
||||
@contextlib.contextmanager
|
||||
def ctx_manager(*args, **kwargs):
|
||||
yield
|
||||
|
||||
mocker.patch("libs.flask_utils.preserve_flask_contexts", ctx_manager)
|
||||
|
||||
def _wire(self, generator, mocker: MockerFixture, *, run_side_effect=None, handled=False):
|
||||
generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock(id="conv"))
|
||||
generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock(id="msg"))
|
||||
generator._run_input_guards = mocker.MagicMock(return_value=(handled, "query"))
|
||||
generator._resolve_agent_by_id = mocker.MagicMock(
|
||||
return_value=(mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock())
|
||||
)
|
||||
mocker.patch(f"{MODULE}.db.session.get", return_value=mocker.MagicMock(id="app1"))
|
||||
mocker.patch(f"{MODULE}.db.session.close")
|
||||
mocker.patch(f"{MODULE}.DifyRunContext", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.build_dify_model_access", return_value=(mocker.MagicMock(), None))
|
||||
mocker.patch(f"{MODULE}.AgentAppRuntimeRequestBuilder", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.create_agent_backend_run_client", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.AgentBackendRunEventAdapter", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.AgentAppRuntimeSessionStore", return_value=mocker.MagicMock())
|
||||
runner = mocker.MagicMock()
|
||||
if run_side_effect is not None:
|
||||
runner.run.side_effect = run_side_effect
|
||||
mocker.patch(f"{MODULE}.AgentAppRunner", return_value=runner)
|
||||
return runner
|
||||
|
||||
def _call(self, generator, mocker: MockerFixture, queue_manager):
|
||||
generator._generate_worker(
|
||||
flask_app=mocker.MagicMock(),
|
||||
context=mocker.MagicMock(),
|
||||
application_generate_entity=mocker.MagicMock(
|
||||
agent_id="a", agent_config_snapshot_id="s", model_conf=mocker.MagicMock(model="m")
|
||||
),
|
||||
queue_manager=queue_manager,
|
||||
conversation_id="conv",
|
||||
message_id="msg",
|
||||
user_from=UserFrom.END_USER,
|
||||
)
|
||||
|
||||
def test_happy_path_runs_backend(self, generator, mocker: MockerFixture):
|
||||
runner = self._wire(generator, mocker)
|
||||
queue_manager = mocker.MagicMock()
|
||||
self._call(generator, mocker, queue_manager)
|
||||
runner.run.assert_called_once()
|
||||
queue_manager.publish_error.assert_not_called()
|
||||
|
||||
def test_input_guard_short_circuit_skips_backend(self, generator, mocker: MockerFixture):
|
||||
runner = self._wire(generator, mocker, handled=True)
|
||||
queue_manager = mocker.MagicMock()
|
||||
self._call(generator, mocker, queue_manager)
|
||||
runner.run.assert_not_called()
|
||||
|
||||
def test_generate_task_stopped_is_swallowed(self, generator, mocker: MockerFixture):
|
||||
self._wire(generator, mocker, run_side_effect=GenerateTaskStoppedError())
|
||||
queue_manager = mocker.MagicMock()
|
||||
self._call(generator, mocker, queue_manager)
|
||||
queue_manager.publish_error.assert_not_called()
|
||||
|
||||
def test_unexpected_error_is_published(self, generator, mocker: MockerFixture):
|
||||
self._wire(generator, mocker, run_side_effect=ValueError("boom"))
|
||||
queue_manager = mocker.MagicMock()
|
||||
self._call(generator, mocker, queue_manager)
|
||||
assert queue_manager.publish_error.called
|
||||
183
api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py
Normal file
183
api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""Unit tests for the Agent App runner — verifies the agent-backend event
|
||||
stream is republished as chat queue events and the conversation snapshot is
|
||||
saved, using the deterministic fake backend client (no live stack)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.protocol import CancelRunRequest, CancelRunResponse
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendError,
|
||||
AgentBackendRunEventAdapter,
|
||||
FakeAgentBackendRunClient,
|
||||
FakeAgentBackendScenario,
|
||||
)
|
||||
from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.session_store import AgentAppSessionScope
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
class _FakeCredentialsProvider:
|
||||
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]:
|
||||
return {"openai_api_key": "sk-test"}
|
||||
|
||||
|
||||
class _NoToolsBuilder:
|
||||
def build(self, **kwargs):
|
||||
del kwargs
|
||||
|
||||
|
||||
class _FakeQueueManager:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[Any] = []
|
||||
|
||||
def publish(self, event: Any, _from: Any) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
def is_stopped(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _StoppedQueueManager(_FakeQueueManager):
|
||||
def is_stopped(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.cancelled_run_ids: list[str] = []
|
||||
|
||||
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||
self.cancelled_run_ids.append(run_id)
|
||||
return super().cancel_run(run_id, request=request)
|
||||
|
||||
|
||||
class _FakeSessionStore:
|
||||
def __init__(self, loaded: CompositorSessionSnapshot | None = None) -> None:
|
||||
self.loaded = loaded
|
||||
self.saved: list[tuple[AgentAppSessionScope, str, CompositorSessionSnapshot | None]] = []
|
||||
|
||||
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
|
||||
return self.loaded
|
||||
|
||||
def save_active_snapshot(self, *, scope, backend_run_id, snapshot) -> None:
|
||||
self.saved.append((scope, backend_run_id, snapshot))
|
||||
|
||||
|
||||
def _soul() -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"model": {
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "langgenius/openai/openai",
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
"prompt": {"system_prompt": "You are Iris."},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _dify_ctx() -> Any:
|
||||
return SimpleNamespace(tenant_id="tenant-1", app_id="app-1", user_id="user-1", invoke_from=InvokeFrom.WEB_APP)
|
||||
|
||||
|
||||
def _runner(client: FakeAgentBackendRunClient, store: _FakeSessionStore) -> AgentAppRunner:
|
||||
return AgentAppRunner(
|
||||
request_builder=AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
),
|
||||
agent_backend_client=client,
|
||||
event_adapter=AgentBackendRunEventAdapter(),
|
||||
session_store=store, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None:
|
||||
runner.run(
|
||||
dify_context=_dify_ctx(),
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-1",
|
||||
agent_soul=_soul(),
|
||||
conversation_id="conv-1",
|
||||
query="hello",
|
||||
message_id="msg-1",
|
||||
model_name="gpt-4o-mini",
|
||||
queue_manager=qm, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_successful_turn_publishes_chunk_and_message_end_and_saves_session():
|
||||
client = FakeAgentBackendRunClient() # SUCCESS: output {"text": "hello agent"}
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
# One LLM chunk + one message-end, carrying the backend's answer text.
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert len(chunk_events) == 1
|
||||
assert len(end_events) == 1
|
||||
assert chunk_events[0].chunk.delta.message.content == "hello agent"
|
||||
assert end_events[0].llm_result.message.content == "hello agent"
|
||||
assert end_events[0].llm_result.model == "gpt-4o-mini"
|
||||
# The conversation session snapshot is persisted for multi-turn continuity.
|
||||
assert store.saved
|
||||
saved_scope, saved_run_id, saved_snapshot = store.saved[0]
|
||||
assert saved_scope.conversation_id == "conv-1"
|
||||
assert saved_scope.agent_config_snapshot_id == "snap-1"
|
||||
assert saved_run_id == "fake-run-1"
|
||||
assert saved_snapshot is not None
|
||||
|
||||
|
||||
def test_prior_session_snapshot_is_threaded_into_request():
|
||||
prior = CompositorSessionSnapshot(layers=[])
|
||||
client = FakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore(loaded=prior)
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
assert client.request is not None
|
||||
assert client.request.session_snapshot is prior
|
||||
|
||||
|
||||
def test_failed_run_raises_agent_backend_error():
|
||||
client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED)
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
with pytest.raises(AgentBackendError):
|
||||
_run(_runner(client, store), qm)
|
||||
# No message-end on failure; no snapshot saved.
|
||||
assert not [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert store.saved == []
|
||||
|
||||
|
||||
def test_stopped_task_cancels_agent_backend_run_and_skips_session_save():
|
||||
client = _RecordingFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
qm = _StoppedQueueManager()
|
||||
|
||||
with pytest.raises(GenerateTaskStoppedError):
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
assert client.cancelled_run_ids == ["fake-run-1"]
|
||||
assert store.saved == []
|
||||
|
||||
|
||||
def test_extract_answer_handles_plain_string_and_dict():
|
||||
assert AgentAppRunner._extract_answer("plain text") == "plain text"
|
||||
assert AgentAppRunner._extract_answer({"text": "hi"}) == "hi"
|
||||
assert AgentAppRunner._extract_answer({"a": 1}) == '{"a": 1}'
|
||||
@ -0,0 +1,133 @@
|
||||
"""Unit tests for AgentAppGenerator._run_input_guards.
|
||||
|
||||
The guards apply content moderation + annotation reply before the Agent backend
|
||||
call, mirroring the EasyUI chat runners. They can short-circuit the turn (a
|
||||
blocked/preset moderation answer or a matched annotation) and publish a direct
|
||||
answer, or pass through a possibly moderation-sanitized query.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import core.app.features.annotation_reply.annotation_reply as annotation_mod
|
||||
import core.moderation.input_moderation as input_moderation_mod
|
||||
from core.app.apps.agent_app.app_generator import AgentAppGenerator
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueAnnotationReplyEvent,
|
||||
QueueLLMChunkEvent,
|
||||
QueueMessageEndEvent,
|
||||
)
|
||||
from core.moderation.base import ModerationError
|
||||
|
||||
|
||||
class _FakeQueueManager:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[Any] = []
|
||||
|
||||
def publish(self, event: Any, _from: Any) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
def _make_entity(query: str = "hello") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
app_config=SimpleNamespace(app_id="app-1", tenant_id="tenant-1"),
|
||||
model_conf=SimpleNamespace(model="gpt-4o-mini"),
|
||||
inputs={},
|
||||
query=query,
|
||||
trace_manager=None,
|
||||
user_id="user-1",
|
||||
invoke_from=SimpleNamespace(),
|
||||
)
|
||||
|
||||
|
||||
def _patch_moderation(monkeypatch, *, returns=None, raises: Exception | None = None) -> None:
|
||||
class _FakeModeration:
|
||||
def check(self, **kwargs: Any):
|
||||
if raises is not None:
|
||||
raise raises
|
||||
return returns
|
||||
|
||||
monkeypatch.setattr(input_moderation_mod, "InputModeration", _FakeModeration)
|
||||
|
||||
|
||||
def _patch_annotation(monkeypatch, *, reply=None) -> None:
|
||||
class _FakeAnnotation:
|
||||
def query(self, **kwargs: Any):
|
||||
return reply
|
||||
|
||||
monkeypatch.setattr(annotation_mod, "AnnotationReplyFeature", _FakeAnnotation)
|
||||
|
||||
|
||||
def _answer_text(events: list[Any]) -> str:
|
||||
end = next(e for e in events if isinstance(e, QueueMessageEndEvent))
|
||||
return end.llm_result.message.content
|
||||
|
||||
|
||||
class TestRunInputGuards:
|
||||
def test_no_guards_passes_through(self, monkeypatch):
|
||||
_patch_moderation(monkeypatch, returns=(False, {}, "hello"))
|
||||
_patch_annotation(monkeypatch, reply=None)
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
handled, query = AgentAppGenerator()._run_input_guards(
|
||||
application_generate_entity=_make_entity("hello"),
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
message=SimpleNamespace(id="msg-1"),
|
||||
queue_manager=qm,
|
||||
)
|
||||
|
||||
assert handled is False
|
||||
assert query == "hello"
|
||||
assert qm.events == []
|
||||
|
||||
def test_moderation_override_sanitizes_query(self, monkeypatch):
|
||||
_patch_moderation(monkeypatch, returns=(True, {}, "[redacted]"))
|
||||
_patch_annotation(monkeypatch, reply=None)
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
handled, query = AgentAppGenerator()._run_input_guards(
|
||||
application_generate_entity=_make_entity("leak my secret"),
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
message=SimpleNamespace(id="msg-1"),
|
||||
queue_manager=qm,
|
||||
)
|
||||
|
||||
assert handled is False
|
||||
assert query == "[redacted]"
|
||||
assert qm.events == []
|
||||
|
||||
def test_moderation_block_short_circuits(self, monkeypatch):
|
||||
_patch_moderation(monkeypatch, raises=ModerationError("blocked preset answer"))
|
||||
_patch_annotation(monkeypatch, reply=None)
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
handled, _ = AgentAppGenerator()._run_input_guards(
|
||||
application_generate_entity=_make_entity("forbidden"),
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
message=SimpleNamespace(id="msg-1"),
|
||||
queue_manager=qm,
|
||||
)
|
||||
|
||||
assert handled is True
|
||||
assert any(isinstance(e, QueueLLMChunkEvent) for e in qm.events)
|
||||
assert _answer_text(qm.events) == "blocked preset answer"
|
||||
|
||||
def test_annotation_hit_short_circuits(self, monkeypatch):
|
||||
_patch_moderation(monkeypatch, returns=(False, {}, "what is your name"))
|
||||
_patch_annotation(monkeypatch, reply=SimpleNamespace(id="anno-1", content="I am the annotated Iris."))
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
handled, _ = AgentAppGenerator()._run_input_guards(
|
||||
application_generate_entity=_make_entity("what is your name"),
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
message=SimpleNamespace(id="msg-1"),
|
||||
queue_manager=qm,
|
||||
)
|
||||
|
||||
assert handled is True
|
||||
annotation_events = [e for e in qm.events if isinstance(e, QueueAnnotationReplyEvent)]
|
||||
assert len(annotation_events) == 1
|
||||
assert annotation_events[0].message_annotation_id == "anno-1"
|
||||
assert _answer_text(qm.events) == "I am the annotated Iris."
|
||||
@ -0,0 +1,97 @@
|
||||
"""Unit tests for AgentAppGenerator agent/snapshot resolution.
|
||||
|
||||
Covers the DB-backed resolution helpers (the bound roster Agent + its published
|
||||
Agent Soul snapshot) including every not-found error path, using a fake session
|
||||
that returns queued rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.agent_app import app_generator as gen_mod
|
||||
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError
|
||||
|
||||
_SOUL_DICT = {
|
||||
"model": {
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "langgenius/openai/openai",
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
"prompt": {"system_prompt": "You are Iris."},
|
||||
}
|
||||
|
||||
|
||||
class _FakeScalarSession:
|
||||
"""db.session stub: scalar() pops the next queued row (ignores the stmt)."""
|
||||
|
||||
def __init__(self, values: list[Any]) -> None:
|
||||
self._values = list(values)
|
||||
|
||||
def scalar(self, _stmt: Any) -> Any:
|
||||
return self._values.pop(0) if self._values else None
|
||||
|
||||
|
||||
def _patch_session(monkeypatch, values: list[Any]) -> None:
|
||||
monkeypatch.setattr(gen_mod, "db", SimpleNamespace(session=_FakeScalarSession(values)))
|
||||
|
||||
|
||||
def _snapshot() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="snap-1", config_snapshot_dict=_SOUL_DICT)
|
||||
|
||||
|
||||
class TestResolveAgentById:
|
||||
def test_success_returns_agent_snapshot_soul(self, monkeypatch):
|
||||
agent = SimpleNamespace(id="agent-1")
|
||||
snapshot = _snapshot()
|
||||
_patch_session(monkeypatch, [agent, snapshot])
|
||||
|
||||
resolved_agent, resolved_snapshot, soul = AgentAppGenerator._resolve_agent_by_id(
|
||||
tenant_id="t1", agent_id="agent-1", snapshot_id="snap-1"
|
||||
)
|
||||
|
||||
assert resolved_agent is agent
|
||||
assert resolved_snapshot is snapshot
|
||||
assert soul.prompt.system_prompt == "You are Iris."
|
||||
assert soul.model is not None
|
||||
assert soul.model.model == "gpt-4o-mini"
|
||||
|
||||
def test_agent_missing_raises(self, monkeypatch):
|
||||
_patch_session(monkeypatch, [None])
|
||||
with pytest.raises(AgentAppGeneratorError, match="Agent not found"):
|
||||
AgentAppGenerator._resolve_agent_by_id(tenant_id="t1", agent_id="x", snapshot_id="snap-1")
|
||||
|
||||
def test_no_published_version_raises(self, monkeypatch):
|
||||
_patch_session(monkeypatch, [SimpleNamespace(id="agent-1")])
|
||||
with pytest.raises(AgentAppGeneratorError, match="no published version"):
|
||||
AgentAppGenerator._resolve_agent_by_id(tenant_id="t1", agent_id="agent-1", snapshot_id=None)
|
||||
|
||||
def test_snapshot_missing_raises(self, monkeypatch):
|
||||
_patch_session(monkeypatch, [SimpleNamespace(id="agent-1"), None])
|
||||
with pytest.raises(AgentAppGeneratorError, match="published version not found"):
|
||||
AgentAppGenerator._resolve_agent_by_id(tenant_id="t1", agent_id="agent-1", snapshot_id="snap-1")
|
||||
|
||||
|
||||
class TestResolveAgent:
|
||||
def test_success_chains_to_resolve_by_id(self, monkeypatch):
|
||||
bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1")
|
||||
inner_agent = SimpleNamespace(id="agent-1")
|
||||
snapshot = _snapshot()
|
||||
# scalar order: bound agent (in _resolve_agent), then agent + snapshot (in _resolve_agent_by_id)
|
||||
_patch_session(monkeypatch, [bound_agent, inner_agent, snapshot])
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
|
||||
|
||||
agent, snap, soul = AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type]
|
||||
|
||||
assert agent is inner_agent
|
||||
assert snap is snapshot
|
||||
assert soul.model is not None
|
||||
|
||||
def test_unbound_app_raises(self, monkeypatch):
|
||||
_patch_session(monkeypatch, [None])
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
|
||||
with pytest.raises(AgentAppGeneratorError, match="has no bound Agent"):
|
||||
AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type]
|
||||
@ -0,0 +1,144 @@
|
||||
"""Unit tests for the Agent App runtime request builder + the app-shaped
|
||||
``AgentBackendRunRequestBuilder.build_for_agent_app`` DTO assembler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendAgentAppRunInput,
|
||||
AgentBackendModelConfig,
|
||||
AgentBackendRunRequestBuilder,
|
||||
)
|
||||
from core.app.apps.agent_app.runtime_request_builder import (
|
||||
AgentAppRuntimeBuildContext,
|
||||
AgentAppRuntimeRequestBuilder,
|
||||
AgentAppRuntimeRequestBuildError,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def _exec_ctx() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="agent_app")
|
||||
|
||||
|
||||
class TestBuildForAgentApp:
|
||||
def test_layers_have_no_workflow_job_prompt_and_include_history(self):
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(
|
||||
AgentBackendAgentAppRunInput(
|
||||
model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
execution_context=_exec_ctx(),
|
||||
user_prompt="hello",
|
||||
agent_soul_prompt="You are Iris.",
|
||||
)
|
||||
)
|
||||
names = [layer.name for layer in request.composition.layers]
|
||||
assert names == [
|
||||
"agent_soul_prompt",
|
||||
"agent_app_user_prompt",
|
||||
"execution_context",
|
||||
"history",
|
||||
"llm",
|
||||
]
|
||||
assert "workflow_node_job_prompt" not in names
|
||||
assert request.purpose == "agent_app"
|
||||
# Agent App keeps layers alive across turns by default.
|
||||
assert request.on_exit.default.value == "suspend"
|
||||
|
||||
def test_blank_user_prompt_rejected(self):
|
||||
with pytest.raises(ValueError, match="must not be blank"):
|
||||
AgentBackendAgentAppRunInput(
|
||||
model=AgentBackendModelConfig(plugin_id="p/q", model_provider="openai", model="m"),
|
||||
execution_context=_exec_ctx(),
|
||||
user_prompt=" ",
|
||||
)
|
||||
|
||||
def test_soul_prompt_optional(self):
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(
|
||||
AgentBackendAgentAppRunInput(
|
||||
model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
execution_context=_exec_ctx(),
|
||||
user_prompt="hi",
|
||||
)
|
||||
)
|
||||
assert [layer.name for layer in request.composition.layers][0] == "agent_app_user_prompt"
|
||||
|
||||
|
||||
class _FakeCredentialsProvider:
|
||||
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]:
|
||||
return {"openai_api_key": "sk-test", "max": 5}
|
||||
|
||||
|
||||
class _NoToolsBuilder:
|
||||
def build(self, **kwargs):
|
||||
del kwargs
|
||||
|
||||
|
||||
def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuildContext:
|
||||
dify_context = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
user_id="user-1",
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
return AgentAppRuntimeBuildContext(
|
||||
dify_context=dify_context, # type: ignore[arg-type]
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-1",
|
||||
agent_soul=soul,
|
||||
conversation_id="conv-1",
|
||||
user_query=query,
|
||||
idempotency_key="msg-1",
|
||||
)
|
||||
|
||||
|
||||
def _soul_with_model() -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"model": {
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "langgenius/openai/openai",
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
"prompt": {"system_prompt": "You are Iris."},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestAgentAppRuntimeRequestBuilder:
|
||||
def test_build_maps_soul_to_run_request(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
result = builder.build(_ctx(_soul_with_model()))
|
||||
|
||||
req = result.request
|
||||
assert req.purpose == "agent_app"
|
||||
names = [layer.name for layer in req.composition.layers]
|
||||
assert names == ["agent_soul_prompt", "agent_app_user_prompt", "execution_context", "history", "llm"]
|
||||
# plugin_id / provider normalized for plugin-daemon transport.
|
||||
llm = next(layer for layer in req.composition.layers if layer.name == "llm")
|
||||
assert llm.config.plugin_id == "langgenius/openai"
|
||||
assert llm.config.model_provider == "openai"
|
||||
# execution context carries conversation + agent_app invoke source.
|
||||
exec_ctx = next(layer for layer in req.composition.layers if layer.name == "execution_context")
|
||||
assert exec_ctx.config.conversation_id == "conv-1"
|
||||
assert exec_ctx.config.invoke_from == "agent_app"
|
||||
# credentials are redacted in the log-safe view.
|
||||
assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]"
|
||||
assert result.metadata["conversation_id"] == "conv-1"
|
||||
|
||||
def test_build_raises_when_model_missing(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
with pytest.raises(AgentAppRuntimeRequestBuildError) as exc:
|
||||
builder.build(_ctx(AgentSoulConfig()))
|
||||
assert exc.value.error_code == "agent_model_not_configured"
|
||||
@ -0,0 +1,138 @@
|
||||
"""Unit tests for the conversation-keyed Agent App session store.
|
||||
|
||||
Exercises the real ORM round-trip against the project's in-memory SQLite engine
|
||||
(per-test create/drop of the unified ``agent_runtime_sessions`` table), so the
|
||||
conversation owner path is verified without Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||
from agenton.layers.base import LifecycleState
|
||||
from sqlalchemy import delete
|
||||
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope
|
||||
from core.db.session_factory import session_factory
|
||||
from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus
|
||||
|
||||
|
||||
def _scope(
|
||||
conversation_id: str = "conv-1", agent_id: str = "agent-1", agent_config_snapshot_id: str = "snap-1"
|
||||
) -> AgentAppSessionScope:
|
||||
return AgentAppSessionScope(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
conversation_id=conversation_id,
|
||||
agent_id=agent_id,
|
||||
agent_config_snapshot_id=agent_config_snapshot_id,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(messages: int = 1) -> CompositorSessionSnapshot:
|
||||
return CompositorSessionSnapshot(
|
||||
layers=[
|
||||
LayerSessionSnapshot(
|
||||
name="history",
|
||||
lifecycle_state=LifecycleState.SUSPENDED,
|
||||
runtime_state={"messages": [{"role": "user", "content": f"m{i}"} for i in range(messages)]},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _create_table() -> Generator[None, None, None]:
|
||||
engine = session_factory.get_session_maker().kw["bind"]
|
||||
AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True)
|
||||
yield
|
||||
with session_factory.create_session() as session:
|
||||
session.execute(delete(AgentRuntimeSession))
|
||||
session.commit()
|
||||
AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True)
|
||||
|
||||
|
||||
def test_load_returns_none_when_no_row():
|
||||
assert AgentAppRuntimeSessionStore().load_active_snapshot(_scope()) is None
|
||||
|
||||
|
||||
def test_save_creates_conversation_owned_row_and_round_trips():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=2))
|
||||
|
||||
loaded = store.load_active_snapshot(_scope())
|
||||
assert loaded is not None
|
||||
assert loaded.layers[0].runtime_state["messages"] == [
|
||||
{"role": "user", "content": "m0"},
|
||||
{"role": "user", "content": "m1"},
|
||||
]
|
||||
with session_factory.create_session() as session:
|
||||
row = session.query(AgentRuntimeSession).one()
|
||||
assert row.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION
|
||||
assert row.conversation_id == "conv-1"
|
||||
assert row.agent_config_snapshot_id == "snap-1"
|
||||
assert row.workflow_run_id is None # conversation owner leaves workflow cols NULL
|
||||
assert row.backend_run_id == "run-1"
|
||||
|
||||
|
||||
def test_save_is_noop_when_snapshot_missing():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-x", snapshot=None)
|
||||
with session_factory.create_session() as session:
|
||||
assert session.query(AgentRuntimeSession).count() == 0
|
||||
|
||||
|
||||
def test_second_turn_updates_same_conversation_row():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=1))
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=3))
|
||||
with session_factory.create_session() as session:
|
||||
rows = session.query(AgentRuntimeSession).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].backend_run_id == "run-2"
|
||||
|
||||
|
||||
def test_mark_cleaned_then_load_returns_none_and_save_resurrects():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot())
|
||||
store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1")
|
||||
assert store.load_active_snapshot(_scope()) is None
|
||||
# Re-entry revives the row.
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=2))
|
||||
with session_factory.create_session() as session:
|
||||
row = session.query(AgentRuntimeSession).one()
|
||||
assert row.status == AgentRuntimeSessionStatus.ACTIVE
|
||||
assert row.cleaned_at is None
|
||||
assert row.backend_run_id == "run-2"
|
||||
|
||||
|
||||
def test_distinct_conversations_do_not_collide():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(scope=_scope(conversation_id="conv-A"), backend_run_id="a", snapshot=_snapshot())
|
||||
store.save_active_snapshot(scope=_scope(conversation_id="conv-B"), backend_run_id="b", snapshot=_snapshot())
|
||||
assert store.load_active_snapshot(_scope(conversation_id="conv-A")) is not None
|
||||
assert store.load_active_snapshot(_scope(conversation_id="conv-B")) is not None
|
||||
with session_factory.create_session() as session:
|
||||
assert session.query(AgentRuntimeSession).count() == 2
|
||||
|
||||
|
||||
def test_distinct_agent_config_snapshots_do_not_reuse_prior_session():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(
|
||||
scope=_scope(agent_config_snapshot_id="snap-1"),
|
||||
backend_run_id="a",
|
||||
snapshot=_snapshot(),
|
||||
)
|
||||
store.save_active_snapshot(
|
||||
scope=_scope(agent_config_snapshot_id="snap-2"), backend_run_id="b", snapshot=_snapshot(messages=2)
|
||||
)
|
||||
|
||||
assert store.load_active_snapshot(_scope(agent_config_snapshot_id="snap-1")) is not None
|
||||
assert store.load_active_snapshot(_scope(agent_config_snapshot_id="snap-2")) is not None
|
||||
with session_factory.create_session() as session:
|
||||
rows = session.query(AgentRuntimeSession).order_by(AgentRuntimeSession.backend_run_id).all()
|
||||
assert len(rows) == 2
|
||||
assert [row.agent_config_snapshot_id for row in rows] == ["snap-1", "snap-2"]
|
||||
@ -98,6 +98,7 @@ class TestAppModelValidation:
|
||||
"workflow",
|
||||
"advanced-chat",
|
||||
"agent-chat",
|
||||
"agent",
|
||||
"channel",
|
||||
"rag-pipeline",
|
||||
}
|
||||
|
||||
@ -662,3 +662,100 @@ def test_composer_validator_rejects_stage_4_declared_output_violations():
|
||||
ComposerConfigValidator.validate_node_job_dict(
|
||||
{"declared_outputs": [{"name": "matrix", "type": "array", "array_item": {"type": "array"}}]}
|
||||
)
|
||||
|
||||
|
||||
class TestAgentAppBackingAgent:
|
||||
"""S1: an Agent App (mode=agent) is backed 1:1 by a roster Agent linked via
|
||||
``Agent.app_id``. ``AppService.create_app`` builds the backing agent inside
|
||||
its own transaction, so the helper must add+flush without committing."""
|
||||
|
||||
def test_create_backing_agent_for_app_links_app_and_seeds_default_soul(self):
|
||||
session = FakeSession()
|
||||
service = AgentRosterService(session)
|
||||
|
||||
agent = service.create_backing_agent_for_app(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
app_id="app-1",
|
||||
name="Iris",
|
||||
description="clarifier",
|
||||
)
|
||||
|
||||
# Agent is bound to the app and is a roster/agent_app entry.
|
||||
assert agent.app_id == "app-1"
|
||||
assert agent.scope == AgentScope.ROSTER
|
||||
assert agent.source == AgentSource.AGENT_APP
|
||||
assert agent.status == AgentStatus.ACTIVE
|
||||
assert agent.agent_kind == AgentKind.DIFY_AGENT
|
||||
assert agent.name == "Iris"
|
||||
# A v1 snapshot + revision are seeded and wired as the active version.
|
||||
snapshots = [a for a in session.added if isinstance(a, AgentConfigSnapshot)]
|
||||
assert len(snapshots) == 1
|
||||
assert snapshots[0].version == 1
|
||||
assert agent.active_config_snapshot_id == snapshots[0].id
|
||||
revisions = [
|
||||
a for a in session.added if getattr(a, "operation", None) == AgentConfigRevisionOperation.CREATE_VERSION
|
||||
]
|
||||
assert len(revisions) == 1
|
||||
# Caller (AppService.create_app) owns the commit — helper must not commit.
|
||||
assert session.commits == 0
|
||||
|
||||
def test_get_app_backing_agent_queries_active_agent_app_agent(self):
|
||||
sentinel = SimpleNamespace(id="agent-1", app_id="app-1")
|
||||
session = FakeSession(scalar=[sentinel])
|
||||
service = AgentRosterService(session)
|
||||
|
||||
result = service.get_app_backing_agent(tenant_id="tenant-1", app_id="app-1")
|
||||
|
||||
assert result is sentinel
|
||||
|
||||
def test_get_app_backing_agent_returns_none_when_unbound(self):
|
||||
session = FakeSession()
|
||||
service = AgentRosterService(session)
|
||||
|
||||
assert service.get_app_backing_agent(tenant_id="tenant-1", app_id="app-x") is None
|
||||
|
||||
|
||||
class TestListWorkflowsReferencingAppAgent:
|
||||
def test_groups_bindings_by_workflow_app_and_sorts_by_name(self):
|
||||
agent = SimpleNamespace(id="agent-1")
|
||||
bindings = [
|
||||
SimpleNamespace(app_id="wf-app-1", workflow_id="wf-1", node_id="node-b"),
|
||||
SimpleNamespace(app_id="wf-app-1", workflow_id="wf-1", node_id="node-a"),
|
||||
SimpleNamespace(app_id="wf-app-2", workflow_id="wf-2", node_id="node-a"),
|
||||
]
|
||||
apps = [
|
||||
SimpleNamespace(id="wf-app-1", name="Beta Flow", mode="workflow"),
|
||||
SimpleNamespace(id="wf-app-2", name="Alpha Flow", mode="advanced-chat"),
|
||||
]
|
||||
# scalar -> backing agent; scalars -> bindings, then resolved apps.
|
||||
session = FakeSession(scalar=[agent], scalars=[bindings, apps])
|
||||
service = AgentRosterService(session)
|
||||
|
||||
result = service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1")
|
||||
|
||||
assert [r["app_name"] for r in result] == ["Alpha Flow", "Beta Flow"]
|
||||
beta = next(r for r in result if r["app_id"] == "wf-app-1")
|
||||
assert beta["node_ids"] == ["node-a", "node-b"] # deduped + sorted
|
||||
assert beta["workflow_id"] == "wf-1"
|
||||
|
||||
def test_returns_empty_when_no_backing_agent(self):
|
||||
session = FakeSession() # scalar() -> None
|
||||
service = AgentRosterService(session)
|
||||
|
||||
assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-x") == []
|
||||
|
||||
def test_returns_empty_when_no_bindings(self):
|
||||
agent = SimpleNamespace(id="agent-1")
|
||||
session = FakeSession(scalar=[agent], scalars=[[]])
|
||||
service = AgentRosterService(session)
|
||||
|
||||
assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") == []
|
||||
|
||||
def test_skips_orphaned_binding_whose_app_is_gone(self):
|
||||
agent = SimpleNamespace(id="agent-1")
|
||||
bindings = [SimpleNamespace(app_id="wf-app-gone", workflow_id="wf-9", node_id="node-a")]
|
||||
session = FakeSession(scalar=[agent], scalars=[bindings, []]) # no apps resolved
|
||||
service = AgentRosterService(session)
|
||||
|
||||
assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") == []
|
||||
|
||||
116
api/tests/unit_tests/services/test_agent_app_feature_service.py
Normal file
116
api/tests/unit_tests/services/test_agent_app_feature_service.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""Unit tests for AgentAppFeatureConfigService.
|
||||
|
||||
validate_features is the security boundary of the Agent App feature endpoint: it
|
||||
must (a) drop any Soul-owned keys a caller tries to smuggle in and (b) fill
|
||||
sane disabled/empty defaults for the presentation features the PRD requires.
|
||||
update_features persists those flags as a new app_model_config version without
|
||||
touching model / prompt / agent_mode.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from services import agent_app_feature_service as svc_mod
|
||||
from services.agent_app_feature_service import AgentAppFeatureConfigService
|
||||
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
class TestValidateFeatures:
|
||||
def test_empty_config_fills_disabled_defaults(self):
|
||||
result = AgentAppFeatureConfigService.validate_features(TENANT_ID, {})
|
||||
|
||||
assert result["opening_statement"] == ""
|
||||
assert result["suggested_questions"] == []
|
||||
assert result["suggested_questions_after_answer"] == {"enabled": False}
|
||||
assert result["retriever_resource"] == {"enabled": False}
|
||||
assert result["speech_to_text"] == {"enabled": False}
|
||||
assert result["text_to_speech"]["enabled"] is False
|
||||
|
||||
def test_opener_and_follow_up_round_trip(self):
|
||||
result = AgentAppFeatureConfigService.validate_features(
|
||||
TENANT_ID,
|
||||
{
|
||||
"opening_statement": "Hi, I'm Iris.",
|
||||
"suggested_questions": ["What can you do?"],
|
||||
"suggested_questions_after_answer": {"enabled": True},
|
||||
"retriever_resource": {"enabled": True},
|
||||
},
|
||||
)
|
||||
|
||||
assert result["opening_statement"] == "Hi, I'm Iris."
|
||||
assert result["suggested_questions"] == ["What can you do?"]
|
||||
assert result["suggested_questions_after_answer"]["enabled"] is True
|
||||
assert result["retriever_resource"]["enabled"] is True
|
||||
|
||||
def test_soul_owned_keys_are_dropped(self):
|
||||
# model / pre_prompt / agent_mode / tools / user_input_form belong to the
|
||||
# Agent Soul and must never be settable through the feature endpoint.
|
||||
result = AgentAppFeatureConfigService.validate_features(
|
||||
TENANT_ID,
|
||||
{
|
||||
"opening_statement": "hello",
|
||||
"model": {"provider": "x", "name": "y"},
|
||||
"pre_prompt": "system override",
|
||||
"agent_mode": {"enabled": True, "strategy": "react"},
|
||||
"tools": [{"a": 1}],
|
||||
"user_input_form": [{"text-input": {}}],
|
||||
},
|
||||
)
|
||||
|
||||
for forbidden in ("model", "pre_prompt", "agent_mode", "tools", "user_input_form"):
|
||||
assert forbidden not in result
|
||||
|
||||
def test_invalid_opening_statement_type_raises(self):
|
||||
with pytest.raises(ValueError, match="opening_statement must be of string type"):
|
||||
AgentAppFeatureConfigService.validate_features(TENANT_ID, {"opening_statement": 123})
|
||||
|
||||
def test_invalid_suggested_questions_type_raises(self):
|
||||
with pytest.raises(ValueError, match="suggested_questions must be of list type"):
|
||||
AgentAppFeatureConfigService.validate_features(TENANT_ID, {"suggested_questions": "nope"})
|
||||
|
||||
|
||||
class _FakeWriteSession:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[Any] = []
|
||||
self.flushed = 0
|
||||
self.committed = 0
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed += 1
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed += 1
|
||||
|
||||
|
||||
class TestUpdateFeatures:
|
||||
def test_persists_new_app_model_config_version(self, monkeypatch):
|
||||
session = _FakeWriteSession()
|
||||
monkeypatch.setattr(svc_mod.db, "session", session)
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id=TENANT_ID, id="app-1", app_model_config_id=None, updated_by=None, updated_at=None
|
||||
)
|
||||
account = SimpleNamespace(id="acct-1")
|
||||
|
||||
new_config = AgentAppFeatureConfigService.update_features(
|
||||
app_model=app_model, # type: ignore[arg-type]
|
||||
account=account, # type: ignore[arg-type]
|
||||
config={"opening_statement": "Hi!", "suggested_questions_after_answer": {"enabled": True}},
|
||||
)
|
||||
|
||||
# New row carries the features but no Soul-owned model/prompt/agent_mode.
|
||||
assert new_config.app_id == "app-1"
|
||||
assert new_config.opening_statement == "Hi!"
|
||||
assert new_config.model is None
|
||||
assert new_config.agent_mode is None
|
||||
# App is repointed at the new version and the write is committed.
|
||||
assert app_model.app_model_config_id == new_config.id
|
||||
assert app_model.updated_by == "acct-1"
|
||||
assert new_config in session.added
|
||||
assert session.flushed == 1
|
||||
assert session.committed == 1
|
||||
@ -127,3 +127,31 @@ class TestOpenapiVisibilityHelpers:
|
||||
assert out == rows
|
||||
gate.assert_called_once()
|
||||
mock_session.execute.assert_called_once()
|
||||
|
||||
|
||||
class TestAgentAppType:
|
||||
"""S1: new ``AppMode.AGENT`` app type wiring."""
|
||||
|
||||
def test_agent_mode_enum_and_template_exist(self):
|
||||
from constants.model_template import default_app_templates
|
||||
from models.model import AppMode
|
||||
|
||||
assert AppMode.AGENT.value == "agent"
|
||||
assert AppMode.AGENT in default_app_templates
|
||||
# Runtime config comes from the Agent Soul, so no model_config is seeded.
|
||||
assert "model_config" not in default_app_templates[AppMode.AGENT]
|
||||
assert default_app_templates[AppMode.AGENT]["app"]["mode"] == AppMode.AGENT
|
||||
|
||||
def test_create_app_params_accepts_agent_mode(self):
|
||||
from services.app_service import CreateAppParams
|
||||
|
||||
params = CreateAppParams(name="Iris", mode="agent")
|
||||
assert params.mode == "agent"
|
||||
|
||||
def test_bound_agent_id_is_none_for_non_agent_app(self):
|
||||
"""Non-agent apps short-circuit without touching the DB."""
|
||||
from models.model import App, AppMode
|
||||
|
||||
app = App()
|
||||
app.mode = AppMode.CHAT
|
||||
assert app.bound_agent_id is None
|
||||
|
||||
@ -16,6 +16,7 @@ class TestAppTaskService:
|
||||
(AppMode.CHAT, False),
|
||||
(AppMode.COMPLETION, False),
|
||||
(AppMode.AGENT_CHAT, False),
|
||||
(AppMode.AGENT, False),
|
||||
(AppMode.CHANNEL, False),
|
||||
(AppMode.RAG_PIPELINE, False),
|
||||
(AppMode.ADVANCED_CHAT, True),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,7 @@ export type CreateAppPayload = {
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: IconType
|
||||
mode: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
|
||||
mode: 'advanced-chat' | 'agent' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
|
||||
name: string
|
||||
}
|
||||
|
||||
@ -80,6 +80,7 @@ export type AppDetailWithSite = {
|
||||
access_mode?: string | null
|
||||
api_base_url?: string | null
|
||||
app_model_config?: ModelConfig
|
||||
bound_agent_id?: string | null
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
deleted_tools?: Array<DeletedTool>
|
||||
@ -200,6 +201,34 @@ export type AgentComposerValidateResponse = {
|
||||
result: string
|
||||
}
|
||||
|
||||
export type AgentAppFeaturesRequest = {
|
||||
opening_statement?: string | null
|
||||
retriever_resource?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
sensitive_word_avoidance?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
speech_to_text?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
suggested_questions?: Array<string> | null
|
||||
suggested_questions_after_answer?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
text_to_speech?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
}
|
||||
|
||||
export type SimpleResultResponse = {
|
||||
result: string
|
||||
}
|
||||
|
||||
export type AgentReferencingWorkflowsResponse = {
|
||||
data?: Array<AgentReferencingWorkflowResponse>
|
||||
}
|
||||
|
||||
export type AnnotationReplyPayload = {
|
||||
embedding_model_name: string
|
||||
embedding_provider_name: string
|
||||
@ -295,10 +324,6 @@ export type SuggestedQuestionsResponse = {
|
||||
data: Array<string>
|
||||
}
|
||||
|
||||
export type SimpleResultResponse = {
|
||||
result: string
|
||||
}
|
||||
|
||||
export type ConversationPagination = {
|
||||
has_next: boolean
|
||||
items: Array<Conversation>
|
||||
@ -323,7 +348,7 @@ export type CompletionMessagePayload = {
|
||||
inputs: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
model_config: {
|
||||
model_config?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
query?: string
|
||||
@ -1110,6 +1135,14 @@ export type ComposerCandidateCapabilities = {
|
||||
human_roster_available?: boolean
|
||||
}
|
||||
|
||||
export type AgentReferencingWorkflowResponse = {
|
||||
app_id: string
|
||||
app_mode: string
|
||||
app_name: string
|
||||
node_ids?: Array<string>
|
||||
workflow_id: string
|
||||
}
|
||||
|
||||
export type AnnotationHitHistory = {
|
||||
annotation_content?: string | null
|
||||
annotation_question?: string | null
|
||||
@ -1824,7 +1857,15 @@ export type GetAppsData = {
|
||||
query?: {
|
||||
is_created_by_me?: boolean | null
|
||||
limit?: number
|
||||
mode?: 'advanced-chat' | 'agent-chat' | 'all' | 'channel' | 'chat' | 'completion' | 'workflow'
|
||||
mode?:
|
||||
| 'advanced-chat'
|
||||
| 'agent'
|
||||
| 'agent-chat'
|
||||
| 'all'
|
||||
| 'channel'
|
||||
| 'chat'
|
||||
| 'completion'
|
||||
| 'workflow'
|
||||
name?: string | null
|
||||
page?: number
|
||||
tag_ids?: Array<string> | null
|
||||
@ -2236,6 +2277,59 @@ export type PostAppsByAppIdAgentComposerValidateResponses = {
|
||||
export type PostAppsByAppIdAgentComposerValidateResponse
|
||||
= PostAppsByAppIdAgentComposerValidateResponses[keyof PostAppsByAppIdAgentComposerValidateResponses]
|
||||
|
||||
export type PostAppsByAppIdAgentFeaturesData = {
|
||||
body: AgentAppFeaturesRequest
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/agent-features'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdAgentFeaturesErrors = {
|
||||
400: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdAgentFeaturesError
|
||||
= PostAppsByAppIdAgentFeaturesErrors[keyof PostAppsByAppIdAgentFeaturesErrors]
|
||||
|
||||
export type PostAppsByAppIdAgentFeaturesResponses = {
|
||||
200: SimpleResultResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdAgentFeaturesResponse
|
||||
= PostAppsByAppIdAgentFeaturesResponses[keyof PostAppsByAppIdAgentFeaturesResponses]
|
||||
|
||||
export type GetAppsByAppIdAgentReferencingWorkflowsData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/agent-referencing-workflows'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentReferencingWorkflowsErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentReferencingWorkflowsError
|
||||
= GetAppsByAppIdAgentReferencingWorkflowsErrors[keyof GetAppsByAppIdAgentReferencingWorkflowsErrors]
|
||||
|
||||
export type GetAppsByAppIdAgentReferencingWorkflowsResponses = {
|
||||
200: AgentReferencingWorkflowsResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentReferencingWorkflowsResponse
|
||||
= GetAppsByAppIdAgentReferencingWorkflowsResponses[keyof GetAppsByAppIdAgentReferencingWorkflowsResponses]
|
||||
|
||||
export type GetAppsByAppIdAgentLogsData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@ -85,6 +85,31 @@ export const zAgentComposerValidateResponse = z.object({
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentAppFeaturesRequest
|
||||
*
|
||||
* Presentation features configurable on an Agent App.
|
||||
*
|
||||
* All fields are optional; an omitted field is reset to its disabled/empty
|
||||
* default (the config form sends the full desired feature state on save).
|
||||
*/
|
||||
export const zAgentAppFeaturesRequest = z.object({
|
||||
opening_statement: z.string().nullish(),
|
||||
retriever_resource: z.record(z.string(), z.unknown()).nullish(),
|
||||
sensitive_word_avoidance: z.record(z.string(), z.unknown()).nullish(),
|
||||
speech_to_text: z.record(z.string(), z.unknown()).nullish(),
|
||||
suggested_questions: z.array(z.string()).nullish(),
|
||||
suggested_questions_after_answer: z.record(z.string(), z.unknown()).nullish(),
|
||||
text_to_speech: z.record(z.string(), z.unknown()).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleResultResponse
|
||||
*/
|
||||
export const zSimpleResultResponse = z.object({
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AnnotationReplyPayload
|
||||
*/
|
||||
@ -168,20 +193,13 @@ export const zSuggestedQuestionsResponse = z.object({
|
||||
data: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleResultResponse
|
||||
*/
|
||||
export const zSimpleResultResponse = z.object({
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* CompletionMessagePayload
|
||||
*/
|
||||
export const zCompletionMessagePayload = z.object({
|
||||
files: z.array(z.unknown()).nullish(),
|
||||
inputs: z.record(z.string(), z.unknown()),
|
||||
model_config: z.record(z.string(), z.unknown()),
|
||||
model_config: z.record(z.string(), z.unknown()).optional(),
|
||||
query: z.string().optional().default(''),
|
||||
response_mode: z.enum(['blocking', 'streaming']).optional().default('blocking'),
|
||||
retriever_from: z.string().optional().default('dev'),
|
||||
@ -623,7 +641,7 @@ export const zCreateAppPayload = z.object({
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: zIconType.optional(),
|
||||
mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']),
|
||||
mode: z.enum(['advanced-chat', 'agent', 'agent-chat', 'chat', 'completion', 'workflow']),
|
||||
name: z.string().min(1),
|
||||
})
|
||||
|
||||
@ -801,6 +819,24 @@ export const zComposerCandidateCapabilities = z.object({
|
||||
human_roster_available: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentReferencingWorkflowResponse
|
||||
*/
|
||||
export const zAgentReferencingWorkflowResponse = z.object({
|
||||
app_id: z.string(),
|
||||
app_mode: z.string(),
|
||||
app_name: z.string(),
|
||||
node_ids: z.array(z.string()).optional(),
|
||||
workflow_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentReferencingWorkflowsResponse
|
||||
*/
|
||||
export const zAgentReferencingWorkflowsResponse = z.object({
|
||||
data: z.array(zAgentReferencingWorkflowResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AnnotationHitHistory
|
||||
*/
|
||||
@ -1415,6 +1451,7 @@ export const zAppDetailWithSite = z.object({
|
||||
access_mode: z.string().nullish(),
|
||||
api_base_url: z.string().nullish(),
|
||||
app_model_config: zModelConfig.optional(),
|
||||
bound_agent_id: z.string().nullish(),
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
deleted_tools: z.array(zDeletedTool).optional(),
|
||||
@ -2378,7 +2415,16 @@ export const zGetAppsQuery = z.object({
|
||||
is_created_by_me: z.boolean().nullish(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
mode: z
|
||||
.enum(['advanced-chat', 'agent-chat', 'all', 'channel', 'chat', 'completion', 'workflow'])
|
||||
.enum([
|
||||
'advanced-chat',
|
||||
'agent',
|
||||
'agent-chat',
|
||||
'all',
|
||||
'channel',
|
||||
'chat',
|
||||
'completion',
|
||||
'workflow',
|
||||
])
|
||||
.optional()
|
||||
.default('all'),
|
||||
name: z.string().nullish(),
|
||||
@ -2607,6 +2653,26 @@ export const zPostAppsByAppIdAgentComposerValidatePath = z.object({
|
||||
*/
|
||||
export const zPostAppsByAppIdAgentComposerValidateResponse = zAgentComposerValidateResponse
|
||||
|
||||
export const zPostAppsByAppIdAgentFeaturesBody = zAgentAppFeaturesRequest
|
||||
|
||||
export const zPostAppsByAppIdAgentFeaturesPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Features updated successfully
|
||||
*/
|
||||
export const zPostAppsByAppIdAgentFeaturesResponse = zSimpleResultResponse
|
||||
|
||||
export const zGetAppsByAppIdAgentReferencingWorkflowsPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Referencing workflows listed successfully
|
||||
*/
|
||||
export const zGetAppsByAppIdAgentReferencingWorkflowsResponse = zAgentReferencingWorkflowsResponse
|
||||
|
||||
export const zGetAppsByAppIdAgentLogsPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
@ -23,7 +23,7 @@ export type ChatMessagePayload = {
|
||||
inputs: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
model_config: {
|
||||
model_config?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
parent_message_id?: string | null
|
||||
|
||||
@ -24,7 +24,7 @@ export const zChatMessagePayload = z.object({
|
||||
conversation_id: z.string().nullish(),
|
||||
files: z.array(z.unknown()).nullish(),
|
||||
inputs: z.record(z.string(), z.unknown()),
|
||||
model_config: z.record(z.string(), z.unknown()),
|
||||
model_config: z.record(z.string(), z.unknown()).optional(),
|
||||
parent_message_id: z.string().nullish(),
|
||||
query: z.string(),
|
||||
response_mode: z.enum(['blocking', 'streaming']).optional().default('blocking'),
|
||||
|
||||
@ -86,6 +86,7 @@ export type AppListRow = {
|
||||
|
||||
export type AppMode
|
||||
= | 'advanced-chat'
|
||||
| 'agent'
|
||||
| 'agent-chat'
|
||||
| 'channel'
|
||||
| 'chat'
|
||||
|
||||
@ -28,6 +28,7 @@ export const zAppDescribeQuery = z.object({
|
||||
*/
|
||||
export const zAppMode = z.enum([
|
||||
'advanced-chat',
|
||||
'agent',
|
||||
'agent-chat',
|
||||
'channel',
|
||||
'chat',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user