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:
zyssyz123 2026-06-02 11:50:10 +08:00 committed by GitHub
parent e530e84772
commit e35d23c3cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 3702 additions and 347 deletions

View File

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

View File

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

View File

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

View File

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

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

View 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"}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}'

View File

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

View File

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

View File

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

View File

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

View File

@ -98,6 +98,7 @@ class TestAppModelValidation:
"workflow",
"advanced-chat",
"agent-chat",
"agent",
"channel",
"rag-pipeline",
}

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,7 +23,7 @@ export type ChatMessagePayload = {
inputs: {
[key: string]: unknown
}
model_config: {
model_config?: {
[key: string]: unknown
}
parent_message_id?: string | null

View File

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

View File

@ -86,6 +86,7 @@ export type AppListRow = {
export type AppMode
= | 'advanced-chat'
| 'agent'
| 'agent-chat'
| 'channel'
| 'chat'

View File

@ -28,6 +28,7 @@ export const zAppDescribeQuery = z.object({
*/
export const zAppMode = z.enum([
'advanced-chat',
'agent',
'agent-chat',
'channel',
'chat',