From e35d23c3cb5ff484fc51fbe33c3cc9227a5ad7bd Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Tue, 2 Jun 2026 11:50:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20Agent=20App=20type=20S1=20?= =?UTF-8?q?=E2=80=94=20AppMode.AGENT=20+=20create=20flow=20+=20binding=20(?= =?UTF-8?q?#36829)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/clients/agent_backend/__init__.py | 2 + api/clients/agent_backend/request_builder.py | 130 +++++ api/constants/model_template.py | 11 + api/controllers/console/__init__.py | 4 + .../console/app/agent_app_access.py | 59 ++ .../console/app/agent_app_feature.py | 80 +++ api/controllers/console/app/app.py | 10 +- api/controllers/console/app/completion.py | 34 +- api/controllers/console/app/conversation.py | 6 +- api/controllers/console/app/message.py | 4 +- api/controllers/console/app/statistic.py | 2 +- api/controllers/service_api/app/completion.py | 15 +- .../service_api/app/conversation.py | 6 +- api/controllers/service_api/app/message.py | 4 +- api/controllers/web/completion.py | 17 +- api/controllers/web/conversation.py | 10 +- api/controllers/web/message.py | 4 +- api/core/app/app_config/entities.py | 4 +- api/core/app/apps/agent_app/__init__.py | 0 .../app/apps/agent_app/app_config_manager.py | 106 ++++ api/core/app/apps/agent_app/app_generator.py | 327 +++++++++++ api/core/app/apps/agent_app/app_runner.py | 200 +++++++ .../agent_app/generate_response_converter.py | 15 + .../apps/agent_app/runtime_request_builder.py | 177 ++++++ api/core/app/apps/agent_app/session_store.py | 106 ++++ api/core/app/apps/base_app_queue_manager.py | 4 + api/core/app/entities/app_invoke_entities.py | 15 + .../workflow/nodes/agent_v2/session_store.py | 13 +- ...074d_unify_agent_runtime_sessions_table.py | 146 +++++ api/models/__init__.py | 6 + api/models/agent.py | 108 +++- api/models/model.py | 25 + api/openapi/markdown/console-swagger.md | 84 ++- api/services/agent/roster_service.py | 140 ++++- api/services/agent_app_feature_service.py | 96 ++++ api/services/app_generate_service.py | 10 + api/services/app_model_config_service.py | 4 +- api/services/app_service.py | 40 +- .../console/app/test_create_app_payload.py | 25 + .../service_api/app/test_completion.py | 15 +- .../service_api/app/test_conversation.py | 9 +- .../service_api/app/test_message.py | 6 +- .../core/app/apps/agent_app/__init__.py | 0 .../apps/agent_app/test_app_config_manager.py | 84 +++ .../app/apps/agent_app/test_app_generator.py | 204 +++++++ .../app/apps/agent_app/test_app_runner.py | 183 ++++++ .../app/apps/agent_app/test_input_guards.py | 133 +++++ .../app/apps/agent_app/test_resolve_agent.py | 97 ++++ .../agent_app/test_runtime_request_builder.py | 144 +++++ .../app/apps/agent_app/test_session_store.py | 138 +++++ .../unit_tests/models/test_app_models.py | 1 + .../services/agent/test_agent_services.py | 97 ++++ .../test_agent_app_feature_service.py | 116 ++++ .../unit_tests/services/test_app_service.py | 28 + .../services/test_app_task_service.py | 1 + .../generated/api/console/apps/orpc.gen.ts | 544 ++++++++++-------- .../generated/api/console/apps/types.gen.ts | 108 +++- .../generated/api/console/apps/zod.gen.ts | 86 ++- .../api/console/installed-apps/types.gen.ts | 2 +- .../api/console/installed-apps/zod.gen.ts | 2 +- .../generated/api/openapi/types.gen.ts | 1 + .../generated/api/openapi/zod.gen.ts | 1 + 62 files changed, 3702 insertions(+), 347 deletions(-) create mode 100644 api/controllers/console/app/agent_app_access.py create mode 100644 api/controllers/console/app/agent_app_feature.py create mode 100644 api/core/app/apps/agent_app/__init__.py create mode 100644 api/core/app/apps/agent_app/app_config_manager.py create mode 100644 api/core/app/apps/agent_app/app_generator.py create mode 100644 api/core/app/apps/agent_app/app_runner.py create mode 100644 api/core/app/apps/agent_app/generate_response_converter.py create mode 100644 api/core/app/apps/agent_app/runtime_request_builder.py create mode 100644 api/core/app/apps/agent_app/session_store.py create mode 100644 api/migrations/versions/2026_05_29_1054-121e7346074d_unify_agent_runtime_sessions_table.py create mode 100644 api/services/agent_app_feature_service.py create mode 100644 api/tests/unit_tests/controllers/console/app/test_create_app_payload.py create mode 100644 api/tests/unit_tests/core/app/apps/agent_app/__init__.py create mode 100644 api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py create mode 100644 api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py create mode 100644 api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py create mode 100644 api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py create mode 100644 api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py create mode 100644 api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py create mode 100644 api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py create mode 100644 api/tests/unit_tests/services/test_agent_app_feature_service.py diff --git a/api/clients/agent_backend/__init__.py b/api/clients/agent_backend/__init__.py index 937dcea58d..bbee164d5c 100644 --- a/api/clients/agent_backend/__init__.py +++ b/api/clients/agent_backend/__init__.py @@ -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", diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index e7c7e6f1ac..176b8796f4 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -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, *, diff --git a/api/constants/model_template.py b/api/constants/model_template.py index cacf6b6874..8a027f10e5 100644 --- a/api/constants/model_template.py +++ b/api/constants/model_template.py @@ -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, + }, + }, } diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index a2bd70d3a0..ee4a369c13 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -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", diff --git a/api/controllers/console/app/agent_app_access.py b/api/controllers/console/app/agent_app_access.py new file mode 100644 index 0000000000..bab7752fc9 --- /dev/null +++ b/api/controllers/console/app/agent_app_access.py @@ -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//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") diff --git a/api/controllers/console/app/agent_app_feature.py b/api/controllers/console/app/agent_app_feature.py new file mode 100644 index 0000000000..51142a1b83 --- /dev/null +++ b/api/controllers/console/app/agent_app_feature.py @@ -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//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"} diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index c1c255f206..e44e32c892 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -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 diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 8983a33d16..cfd1d0b869 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -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") diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index db2ca624f1..7c20f02568 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -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 diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 833de38f34..90f30126f4 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -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) diff --git a/api/controllers/console/app/statistic.py b/api/controllers/console/app/statistic.py index 9e3394e6c1..2595ed0081 100644 --- a/api/controllers/console/app/statistic.py +++ b/api/controllers/console/app/statistic.py @@ -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)) diff --git a/api/controllers/service_api/app/completion.py b/api/controllers/service_api/app/completion.py index dea54a11d9..fc5dd269d5 100644 --- a/api/controllers/service_api/app/completion.py +++ b/api/controllers/service_api/app/completion.py @@ -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( diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index cd247c7a8e..b298801ca0 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -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) diff --git a/api/controllers/service_api/app/message.py b/api/controllers/service_api/app/message.py index d26d4c09b8..a77c4fb660 100644 --- a/api/controllers/service_api/app/message.py +++ b/api/controllers/service_api/app/message.py @@ -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: diff --git a/api/controllers/web/completion.py b/api/controllers/web/completion.py index 258493303f..d4c02b6592 100644 --- a/api/controllers/web/completion.py +++ b/api/controllers/web/completion.py @@ -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( diff --git a/api/controllers/web/conversation.py b/api/controllers/web/conversation.py index 7803b11f4e..fd85922207 100644 --- a/api/controllers/web/conversation.py +++ b/api/controllers/web/conversation.py @@ -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) diff --git a/api/controllers/web/message.py b/api/controllers/web/message.py index ee58433679..e40e57c436 100644 --- a/api/controllers/web/message.py +++ b/api/controllers/web/message.py @@ -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) diff --git a/api/core/app/app_config/entities.py b/api/core/app/app_config/entities.py index 53563dc5da..d962681416 100644 --- a/api/core/app/app_config/entities.py +++ b/api/core/app/app_config/entities.py @@ -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 diff --git a/api/core/app/apps/agent_app/__init__.py b/api/core/app/apps/agent_app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/app/apps/agent_app/app_config_manager.py b/api/core/app/apps/agent_app/app_config_manager.py new file mode 100644 index 0000000000..5889c7c285 --- /dev/null +++ b/api/core/app/apps/agent_app/app_config_manager.py @@ -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"] diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py new file mode 100644 index 0000000000..7cbe62bcac --- /dev/null +++ b/api/core/app/apps/agent_app/app_generator.py @@ -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"] diff --git a/api/core/app/apps/agent_app/app_runner.py b/api/core/app/apps/agent_app/app_runner.py new file mode 100644 index 0000000000..5f7d9f7624 --- /dev/null +++ b/api/core/app/apps/agent_app/app_runner.py @@ -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"] diff --git a/api/core/app/apps/agent_app/generate_response_converter.py b/api/core/app/apps/agent_app/generate_response_converter.py new file mode 100644 index 0000000000..678328003e --- /dev/null +++ b/api/core/app/apps/agent_app/generate_response_converter.py @@ -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"] diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py new file mode 100644 index 0000000000..1c4c59ae37 --- /dev/null +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -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", +] diff --git a/api/core/app/apps/agent_app/session_store.py b/api/core/app/apps/agent_app/session_store.py new file mode 100644 index 0000000000..183e7113a9 --- /dev/null +++ b/api/core/app/apps/agent_app/session_store.py @@ -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"] diff --git a/api/core/app/apps/base_app_queue_manager.py b/api/core/app/apps/base_app_queue_manager.py index 4bf29da6e3..b959987078 100644 --- a/api/core/app/apps/base_app_queue_manager.py +++ b/api/core/app/apps/base_app_queue_manager.py @@ -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: """ diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index d4baf1f0df..debda2da19 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -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. diff --git a/api/core/workflow/nodes/agent_v2/session_store.py b/api/core/workflow/nodes/agent_v2/session_store.py index 9c45742489..f3625cb736 100644 --- a/api/core/workflow/nodes/agent_v2/session_store.py +++ b/api/core/workflow/nodes/agent_v2/session_store.py @@ -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, diff --git a/api/migrations/versions/2026_05_29_1054-121e7346074d_unify_agent_runtime_sessions_table.py b/api/migrations/versions/2026_05_29_1054-121e7346074d_unify_agent_runtime_sessions_table.py new file mode 100644 index 0000000000..9803bc3428 --- /dev/null +++ b/api/migrations/versions/2026_05_29_1054-121e7346074d_unify_agent_runtime_sessions_table.py @@ -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"]) diff --git a/api/models/__init__.py b/api/models/__init__.py index c87acab04e..c1c4a17f41 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -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", diff --git a/api/models/agent.py b/api/models/agent.py index 684efd513d..b9bc162d31 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -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 diff --git a/api/models/model.py b/api/models/model.py index 3647fbf6f7..6381d28b28 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -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("/") diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index caaa0216e2..332ca1f2aa 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -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
*Enum:* `"advanced-chat"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | +| mode | string | App mode filter
*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
*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
*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
*Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | +| mode | string | App mode
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | | name | string | App name | Yes | #### CredentialType diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 67300679bf..35b3cbf868 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -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( diff --git a/api/services/agent_app_feature_service.py b/api/services/agent_app_feature_service.py new file mode 100644 index 0000000000..cc0fe67802 --- /dev/null +++ b/api/services/agent_app_feature_service.py @@ -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"] diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index d6c01e9dcc..22fc2da322 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -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( diff --git a/api/services/app_model_config_service.py b/api/services/app_model_config_service.py index 8252de7753..ca42e76b00 100644 --- a/api/services/app_model_config_service.py +++ b/api/services/app_model_config_service.py @@ -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}") diff --git a/api/services/app_service.py b/api/services/app_service.py index bc867e8dc4..8fd84bbdd4 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -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() diff --git a/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py b/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py new file mode 100644 index 0000000000..ebabe50c93 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py @@ -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"}) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_completion.py b/api/tests/unit_tests/controllers/service_api/app/test_completion.py index a60b3b18bd..745df9c798 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_completion.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_completion.py @@ -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" diff --git a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py index 52a7aa2189..abb476a750 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py @@ -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() diff --git a/api/tests/unit_tests/controllers/service_api/app/test_message.py b/api/tests/unit_tests/controllers/service_api/app/test_message.py index d44e92ce66..1fda5ce9cf 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_message.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_message.py @@ -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): diff --git a/api/tests/unit_tests/core/app/apps/agent_app/__init__.py b/api/tests/unit_tests/core/app/apps/agent_app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py new file mode 100644 index 0000000000..6edcaca5a6 --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py @@ -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 diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py new file mode 100644 index 0000000000..a80db85416 --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -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 diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py new file mode 100644 index 0000000000..9a695da9e4 --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -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}' diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py new file mode 100644 index 0000000000..e1c8d51b0d --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py @@ -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." diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py new file mode 100644 index 0000000000..637c0fad5d --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py @@ -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] diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py new file mode 100644 index 0000000000..9f453bab38 --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -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" diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py new file mode 100644 index 0000000000..a806130517 --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py @@ -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"] diff --git a/api/tests/unit_tests/models/test_app_models.py b/api/tests/unit_tests/models/test_app_models.py index 8850137eb9..d3d0c5dce0 100644 --- a/api/tests/unit_tests/models/test_app_models.py +++ b/api/tests/unit_tests/models/test_app_models.py @@ -98,6 +98,7 @@ class TestAppModelValidation: "workflow", "advanced-chat", "agent-chat", + "agent", "channel", "rag-pipeline", } diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 21bbe20ea2..5c58a34dcc 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -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") == [] diff --git a/api/tests/unit_tests/services/test_agent_app_feature_service.py b/api/tests/unit_tests/services/test_agent_app_feature_service.py new file mode 100644 index 0000000000..a8553b62a8 --- /dev/null +++ b/api/tests/unit_tests/services/test_agent_app_feature_service.py @@ -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 diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index 610b32ac3c..b0edfeaf90 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -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 diff --git a/api/tests/unit_tests/services/test_app_task_service.py b/api/tests/unit_tests/services/test_app_task_service.py index 33ca4cb853..2a72da3578 100644 --- a/api/tests/unit_tests/services/test_app_task_service.py +++ b/api/tests/unit_tests/services/test_app_task_service.py @@ -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), diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index f1f973ecab..7047f35a61 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -44,6 +44,8 @@ import { zGetAppsByAppIdAgentLogsPath, zGetAppsByAppIdAgentLogsQuery, zGetAppsByAppIdAgentLogsResponse, + zGetAppsByAppIdAgentReferencingWorkflowsPath, + zGetAppsByAppIdAgentReferencingWorkflowsResponse, zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdPath, zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse, zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdPath, @@ -246,6 +248,9 @@ import { zPostAppsByAppIdAgentComposerValidateBody, zPostAppsByAppIdAgentComposerValidatePath, zPostAppsByAppIdAgentComposerValidateResponse, + zPostAppsByAppIdAgentFeaturesBody, + zPostAppsByAppIdAgentFeaturesPath, + zPostAppsByAppIdAgentFeaturesResponse, zPostAppsByAppIdAnnotationReplyByActionBody, zPostAppsByAppIdAnnotationReplyByActionPath, zPostAppsByAppIdAnnotationReplyByActionResponse, @@ -864,6 +869,55 @@ export const agentComposer = { validate, } +/** + * Update an Agent App's presentation features (opener, follow-up, citations, ...) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post10 = oc + .route({ + deprecated: true, + description: + 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAgentFeatures', + path: '/apps/{app_id}/agent-features', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdAgentFeaturesBody, + params: zPostAppsByAppIdAgentFeaturesPath, + }), + ) + .output(zPostAppsByAppIdAgentFeaturesResponse) + +export const agentFeatures = { + post: post10, +} + +/** + * List workflow apps that reference this Agent App's bound Agent (read-only) + */ +export const get6 = oc + .route({ + description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentReferencingWorkflows', + path: '/apps/{app_id}/agent-referencing-workflows', + tags: ['console'], + }) + .input(z.object({ params: zGetAppsByAppIdAgentReferencingWorkflowsPath })) + .output(zGetAppsByAppIdAgentReferencingWorkflowsResponse) + +export const agentReferencingWorkflows = { + get: get6, +} + /** * Get agent logs * @@ -873,7 +927,7 @@ export const agentComposer = { * * @deprecated */ -export const get6 = oc +export const get7 = oc .route({ deprecated: true, description: @@ -889,7 +943,7 @@ export const get6 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get6, + get: get7, } export const agent = { @@ -903,7 +957,7 @@ export const agent = { * * @deprecated */ -export const get7 = oc +export const get8 = oc .route({ deprecated: true, description: @@ -918,7 +972,7 @@ export const get7 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get7, + get: get8, } export const status = { @@ -932,7 +986,7 @@ export const status = { * * @deprecated */ -export const post10 = oc +export const post11 = oc .route({ deprecated: true, description: @@ -952,7 +1006,7 @@ export const post10 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post10, + post: post11, status, } @@ -967,7 +1021,7 @@ export const annotationReply = { * * @deprecated */ -export const get8 = oc +export const get9 = oc .route({ deprecated: true, description: @@ -982,7 +1036,7 @@ export const get8 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get8, + get: get9, } /** @@ -992,7 +1046,7 @@ export const annotationSetting = { * * @deprecated */ -export const post11 = oc +export const post12 = oc .route({ deprecated: true, description: @@ -1012,7 +1066,7 @@ export const post11 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post11, + post: post12, } export const annotationSettings = { @@ -1026,7 +1080,7 @@ export const annotationSettings = { * * @deprecated */ -export const post12 = oc +export const post13 = oc .route({ deprecated: true, description: @@ -1041,7 +1095,7 @@ export const post12 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post12, + post: post13, } /** @@ -1051,7 +1105,7 @@ export const batchImport = { * * @deprecated */ -export const get9 = oc +export const get10 = oc .route({ deprecated: true, description: @@ -1066,7 +1120,7 @@ export const get9 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get9, + get: get10, } export const batchImportStatus = { @@ -1076,7 +1130,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get10 = oc +export const get11 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -1089,13 +1143,13 @@ export const get10 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get10, + get: get11, } /** * Export all annotations for an app with CSV injection protection */ -export const get11 = oc +export const get12 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -1108,13 +1162,13 @@ export const get11 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get11, + get: get12, } /** * Get hit histories for an annotation */ -export const get12 = oc +export const get13 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -1132,7 +1186,7 @@ export const get12 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get12, + get: get13, } /** @@ -1161,7 +1215,7 @@ export const delete_ = oc * * @deprecated */ -export const post13 = oc +export const post14 = oc .route({ deprecated: true, description: @@ -1182,7 +1236,7 @@ export const post13 = oc export const byAnnotationId = { delete: delete_, - post: post13, + post: post14, hitHistories, } @@ -1212,7 +1266,7 @@ export const delete2 = oc * * @deprecated */ -export const get13 = oc +export const get14 = oc .route({ deprecated: true, description: @@ -1238,7 +1292,7 @@ export const get13 = oc * * @deprecated */ -export const post14 = oc +export const post15 = oc .route({ deprecated: true, description: @@ -1257,8 +1311,8 @@ export const post14 = oc export const annotations = { delete: delete2, - get: get13, - post: post14, + get: get14, + post: post15, batchImport, batchImportStatus, count: count2, @@ -1273,7 +1327,7 @@ export const annotations = { * * @deprecated */ -export const post15 = oc +export const post16 = oc .route({ deprecated: true, description: @@ -1288,13 +1342,13 @@ export const post15 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post15, + post: post16, } /** * Transcript audio to text for chat messages */ -export const post16 = oc +export const post17 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1307,7 +1361,7 @@ export const post16 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post16, + post: post17, } /** @@ -1333,7 +1387,7 @@ export const delete3 = oc * * @deprecated */ -export const get14 = oc +export const get15 = oc .route({ deprecated: true, description: @@ -1349,13 +1403,13 @@ export const get14 = oc export const byConversationId = { delete: delete3, - get: get14, + get: get15, } /** * Get chat conversations with pagination, filtering and summary */ -export const get15 = oc +export const get16 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1373,14 +1427,14 @@ export const get15 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get15, + get: get16, byConversationId, } /** * Get suggested questions for a message */ -export const get16 = oc +export const get17 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1393,7 +1447,7 @@ export const get16 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get16, + get: get17, } export const byMessageId = { @@ -1403,7 +1457,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post17 = oc +export const post18 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1416,7 +1470,7 @@ export const post17 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post17, + post: post18, } export const byTaskId = { @@ -1430,7 +1484,7 @@ export const byTaskId = { * * @deprecated */ -export const get17 = oc +export const get18 = oc .route({ deprecated: true, description: @@ -1447,7 +1501,7 @@ export const get17 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get17, + get: get18, byMessageId, byTaskId, } @@ -1475,7 +1529,7 @@ export const delete4 = oc * * @deprecated */ -export const get18 = oc +export const get19 = oc .route({ deprecated: true, description: @@ -1491,13 +1545,13 @@ export const get18 = oc export const byConversationId2 = { delete: delete4, - get: get18, + get: get19, } /** * Get completion conversations with pagination and filtering */ -export const get19 = oc +export const get20 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1515,14 +1569,14 @@ export const get19 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get19, + get: get20, byConversationId: byConversationId2, } /** * Stop a running completion message generation */ -export const post18 = oc +export const post19 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -1535,7 +1589,7 @@ export const post18 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post18, + post: post19, } export const byTaskId2 = { @@ -1549,7 +1603,7 @@ export const byTaskId2 = { * * @deprecated */ -export const post19 = oc +export const post20 = oc .route({ deprecated: true, description: @@ -1569,14 +1623,14 @@ export const post19 = oc .output(zPostAppsByAppIdCompletionMessagesResponse) export const completionMessages = { - post: post19, + post: post20, byTaskId: byTaskId2, } /** * Get conversation variables for an application */ -export const get20 = oc +export const get21 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -1594,7 +1648,7 @@ export const get20 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get20, + get: get21, } /** @@ -1604,7 +1658,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post20 = oc +export const post21 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -1624,7 +1678,7 @@ export const post20 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post20, + post: post21, } /** @@ -1636,7 +1690,7 @@ export const convertToWorkflow = { * * @deprecated */ -export const post21 = oc +export const post22 = oc .route({ deprecated: true, description: @@ -1653,7 +1707,7 @@ export const post21 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post21, + post: post22, } /** @@ -1661,7 +1715,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get21 = oc +export const get22 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -1677,7 +1731,7 @@ export const get21 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get21, + get: get22, } /** @@ -1687,7 +1741,7 @@ export const export2 = { * * @deprecated */ -export const get22 = oc +export const get23 = oc .route({ deprecated: true, description: @@ -1707,13 +1761,13 @@ export const get22 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get22, + get: get23, } /** * Create or update message feedback (like/dislike) */ -export const post22 = oc +export const post23 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -1726,7 +1780,7 @@ export const post22 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post22, + post: post23, export: export3, } @@ -1737,7 +1791,7 @@ export const feedbacks = { * * @deprecated */ -export const post23 = oc +export const post24 = oc .route({ deprecated: true, description: @@ -1752,7 +1806,7 @@ export const post23 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post23, + post: post24, } /** @@ -1762,7 +1816,7 @@ export const icon = { * * @deprecated */ -export const get23 = oc +export const get24 = oc .route({ deprecated: true, description: @@ -1777,7 +1831,7 @@ export const get23 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get23, + get: get24, } export const messages = { @@ -1793,7 +1847,7 @@ export const messages = { * * @deprecated */ -export const post24 = oc +export const post25 = oc .route({ deprecated: true, description: @@ -1811,7 +1865,7 @@ export const post24 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post24, + post: post25, } /** @@ -1821,7 +1875,7 @@ export const modelConfig = { * * @deprecated */ -export const post25 = oc +export const post26 = oc .route({ deprecated: true, description: @@ -1836,13 +1890,13 @@ export const post25 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post25, + post: post26, } /** * Publish app to Creators Platform */ -export const post26 = oc +export const post27 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1855,7 +1909,7 @@ export const post26 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post26, + post: post27, } /** @@ -1865,7 +1919,7 @@ export const publishToCreatorsPlatform = { * * @deprecated */ -export const get24 = oc +export const get25 = oc .route({ deprecated: true, description: @@ -1886,7 +1940,7 @@ export const get24 = oc * * @deprecated */ -export const post27 = oc +export const post28 = oc .route({ deprecated: true, description: @@ -1923,15 +1977,15 @@ export const put2 = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get24, - post: post27, + get: get25, + post: post28, put: put2, } /** * Reset access token for application site */ -export const post28 = oc +export const post29 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -1944,13 +1998,13 @@ export const post28 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post28, + post: post29, } /** * Update application site configuration */ -export const post29 = oc +export const post30 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -1963,7 +2017,7 @@ export const post29 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post29, + post: post30, accessTokenReset, } @@ -1974,7 +2028,7 @@ export const site = { * * @deprecated */ -export const post30 = oc +export const post31 = oc .route({ deprecated: true, description: @@ -1989,7 +2043,7 @@ export const post30 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post30, + post: post31, } /** @@ -1999,7 +2053,7 @@ export const siteEnable = { * * @deprecated */ -export const get25 = oc +export const get26 = oc .route({ deprecated: true, description: @@ -2019,7 +2073,7 @@ export const get25 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get25, + get: get26, } /** @@ -2029,7 +2083,7 @@ export const averageResponseTime = { * * @deprecated */ -export const get26 = oc +export const get27 = oc .route({ deprecated: true, description: @@ -2049,7 +2103,7 @@ export const get26 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get26, + get: get27, } /** @@ -2059,7 +2113,7 @@ export const averageSessionInteractions = { * * @deprecated */ -export const get27 = oc +export const get28 = oc .route({ deprecated: true, description: @@ -2079,7 +2133,7 @@ export const get27 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get27, + get: get28, } /** @@ -2089,7 +2143,7 @@ export const dailyConversations = { * * @deprecated */ -export const get28 = oc +export const get29 = oc .route({ deprecated: true, description: @@ -2109,7 +2163,7 @@ export const get28 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get28, + get: get29, } /** @@ -2119,7 +2173,7 @@ export const dailyEndUsers = { * * @deprecated */ -export const get29 = oc +export const get30 = oc .route({ deprecated: true, description: @@ -2139,7 +2193,7 @@ export const get29 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get29, + get: get30, } /** @@ -2149,7 +2203,7 @@ export const dailyMessages = { * * @deprecated */ -export const get30 = oc +export const get31 = oc .route({ deprecated: true, description: @@ -2169,7 +2223,7 @@ export const get30 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get30, + get: get31, } /** @@ -2179,7 +2233,7 @@ export const tokenCosts = { * * @deprecated */ -export const get31 = oc +export const get32 = oc .route({ deprecated: true, description: @@ -2199,7 +2253,7 @@ export const get31 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get31, + get: get32, } /** @@ -2209,7 +2263,7 @@ export const tokensPerSecond = { * * @deprecated */ -export const get32 = oc +export const get33 = oc .route({ deprecated: true, description: @@ -2229,7 +2283,7 @@ export const get32 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get32, + get: get33, } export const statistics = { @@ -2250,7 +2304,7 @@ export const statistics = { * * @deprecated */ -export const get33 = oc +export const get34 = oc .route({ deprecated: true, description: @@ -2270,7 +2324,7 @@ export const get33 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get33, + get: get34, } /** @@ -2280,7 +2334,7 @@ export const voices = { * * @deprecated */ -export const post31 = oc +export const post32 = oc .route({ deprecated: true, description: @@ -2297,7 +2351,7 @@ export const post31 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post31, + post: post32, voices, } @@ -2310,7 +2364,7 @@ export const textToAudio = { * * @deprecated */ -export const get34 = oc +export const get35 = oc .route({ deprecated: true, description: @@ -2328,7 +2382,7 @@ export const get34 = oc /** * Update app tracing configuration */ -export const post32 = oc +export const post33 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2341,8 +2395,8 @@ export const post32 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get34, - post: post32, + get: get35, + post: post33, } /** @@ -2376,7 +2430,7 @@ export const delete5 = oc * * @deprecated */ -export const get35 = oc +export const get36 = oc .route({ deprecated: true, description: @@ -2427,7 +2481,7 @@ export const patch = oc * * @deprecated */ -export const post33 = oc +export const post34 = oc .route({ deprecated: true, description: @@ -2447,15 +2501,15 @@ export const post33 = oc export const traceConfig = { delete: delete5, - get: get35, + get: get36, patch, - post: post33, + post: post34, } /** * Update app trigger (enable/disable) */ -export const post34 = oc +export const post35 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2473,13 +2527,13 @@ export const post34 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post34, + post: post35, } /** * Get app triggers list */ -export const get36 = oc +export const get37 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2492,7 +2546,7 @@ export const get36 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get36, + get: get37, } /** @@ -2500,7 +2554,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get37 = oc +export const get38 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2519,7 +2573,7 @@ export const get37 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get37, + get: get38, } /** @@ -2527,7 +2581,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get38 = oc +export const get39 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2546,7 +2600,7 @@ export const get38 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get38, + get: get39, } /** @@ -2554,7 +2608,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get39 = oc +export const get40 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2573,7 +2627,7 @@ export const get39 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get39, + get: get40, } /** @@ -2581,7 +2635,7 @@ export const count3 = { * * Stop running workflow task */ -export const post35 = oc +export const post36 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2595,7 +2649,7 @@ export const post35 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post35, + post: post36, } export const byTaskId3 = { @@ -2609,7 +2663,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get40 = oc +export const get41 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2622,7 +2676,7 @@ export const get40 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get40, + get: get41, } /** @@ -2630,7 +2684,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get41 = oc +export const get42 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2644,7 +2698,7 @@ export const get41 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get41, + get: get42, } /** @@ -2652,7 +2706,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get42 = oc +export const get43 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2666,7 +2720,7 @@ export const get42 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get42, + get: get43, export: export4, nodeExecutions, } @@ -2676,7 +2730,7 @@ export const byRunId = { * * Get workflow run list */ -export const get43 = oc +export const get44 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2695,7 +2749,7 @@ export const get43 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get43, + get: get44, count: count3, tasks, byRunId, @@ -2706,7 +2760,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get44 = oc +export const get45 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2720,7 +2774,7 @@ export const get44 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get44, + get: get45, } /** @@ -2775,7 +2829,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post36 = oc +export const post37 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -2795,7 +2849,7 @@ export const post36 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post36, + post: post37, byReplyId, } @@ -2804,7 +2858,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post37 = oc +export const post38 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -2818,7 +2872,7 @@ export const post37 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post37, + post: post38, } /** @@ -2845,7 +2899,7 @@ export const delete7 = oc * * Get a specific workflow comment */ -export const get45 = oc +export const get46 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -2883,7 +2937,7 @@ export const put4 = oc export const byCommentId = { delete: delete7, - get: get45, + get: get46, put: put4, replies, resolve, @@ -2894,7 +2948,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get46 = oc +export const get47 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -2912,7 +2966,7 @@ export const get46 = oc * * Create a new workflow comment */ -export const post38 = oc +export const post39 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -2932,8 +2986,8 @@ export const post38 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get46, - post: post38, + get: get47, + post: post39, mentionUsers, byCommentId, } @@ -2945,7 +2999,7 @@ export const comments = { * * @deprecated */ -export const get47 = oc +export const get48 = oc .route({ deprecated: true, description: @@ -2965,7 +3019,7 @@ export const get47 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get47, + get: get48, } /** @@ -2975,7 +3029,7 @@ export const averageAppInteractions = { * * @deprecated */ -export const get48 = oc +export const get49 = oc .route({ deprecated: true, description: @@ -2995,7 +3049,7 @@ export const get48 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get48, + get: get49, } /** @@ -3005,7 +3059,7 @@ export const dailyConversations2 = { * * @deprecated */ -export const get49 = oc +export const get50 = oc .route({ deprecated: true, description: @@ -3025,7 +3079,7 @@ export const get49 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get49, + get: get50, } /** @@ -3035,7 +3089,7 @@ export const dailyTerminals = { * * @deprecated */ -export const get50 = oc +export const get51 = oc .route({ deprecated: true, description: @@ -3055,7 +3109,7 @@ export const get50 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get50, + get: get51, } export const statistics2 = { @@ -3079,7 +3133,7 @@ export const workflow = { * * @deprecated */ -export const get51 = oc +export const get52 = oc .route({ deprecated: true, description: @@ -3100,7 +3154,7 @@ export const get51 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get51, + get: get52, } /** @@ -3112,7 +3166,7 @@ export const byBlockType = { * * @deprecated */ -export const get52 = oc +export const get53 = oc .route({ deprecated: true, description: @@ -3128,7 +3182,7 @@ export const get52 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get52, + get: get53, byBlockType, } @@ -3139,7 +3193,7 @@ export const defaultWorkflowBlockConfigs = { * * @deprecated */ -export const get53 = oc +export const get54 = oc .route({ deprecated: true, description: @@ -3160,7 +3214,7 @@ export const get53 = oc * * @deprecated */ -export const post39 = oc +export const post40 = oc .route({ deprecated: true, description: @@ -3180,8 +3234,8 @@ export const post39 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get53, - post: post39, + get: get54, + post: post40, } /** @@ -3193,7 +3247,7 @@ export const conversationVariables2 = { * * @deprecated */ -export const get54 = oc +export const get55 = oc .route({ deprecated: true, description: @@ -3215,7 +3269,7 @@ export const get54 = oc * * @deprecated */ -export const post40 = oc +export const post41 = oc .route({ deprecated: true, description: @@ -3235,8 +3289,8 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get54, - post: post40, + get: get55, + post: post41, } /** @@ -3246,7 +3300,7 @@ export const environmentVariables = { * * @deprecated */ -export const post41 = oc +export const post42 = oc .route({ deprecated: true, description: @@ -3266,7 +3320,7 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post41, + post: post42, } /** @@ -3278,7 +3332,7 @@ export const features = { * * @deprecated */ -export const post42 = oc +export const post43 = oc .route({ deprecated: true, description: @@ -3299,7 +3353,7 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post42, + post: post43, } /** @@ -3311,7 +3365,7 @@ export const deliveryTest = { * * @deprecated */ -export const post43 = oc +export const post44 = oc .route({ deprecated: true, description: @@ -3332,7 +3386,7 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) export const preview2 = { - post: post43, + post: post44, } /** @@ -3344,7 +3398,7 @@ export const preview2 = { * * @deprecated */ -export const post44 = oc +export const post45 = oc .route({ deprecated: true, description: @@ -3365,7 +3419,7 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post44, + post: post45, } export const form2 = { @@ -3395,7 +3449,7 @@ export const humanInput2 = { * * @deprecated */ -export const post45 = oc +export const post46 = oc .route({ deprecated: true, description: @@ -3416,7 +3470,7 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post45, + post: post46, } export const byNodeId5 = { @@ -3440,7 +3494,7 @@ export const iteration2 = { * * @deprecated */ -export const post46 = oc +export const post47 = oc .route({ deprecated: true, description: @@ -3461,7 +3515,7 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post46, + post: post47, } export const byNodeId6 = { @@ -3481,7 +3535,7 @@ export const loop2 = { * * @deprecated */ -export const get55 = oc +export const get56 = oc .route({ deprecated: true, description: @@ -3498,7 +3552,7 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) export const candidates2 = { - get: get55, + get: get56, } /** @@ -3506,7 +3560,7 @@ export const candidates2 = { * * @deprecated */ -export const post47 = oc +export const post48 = oc .route({ deprecated: true, description: @@ -3526,7 +3580,7 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post47, + post: post48, } /** @@ -3534,7 +3588,7 @@ export const impact = { * * @deprecated */ -export const post48 = oc +export const post49 = oc .route({ deprecated: true, description: @@ -3554,7 +3608,7 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post48, + post: post49, } /** @@ -3562,7 +3616,7 @@ export const saveToRoster = { * * @deprecated */ -export const post49 = oc +export const post50 = oc .route({ deprecated: true, description: @@ -3582,7 +3636,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate2 = { - post: post49, + post: post50, } /** @@ -3590,7 +3644,7 @@ export const validate2 = { * * @deprecated */ -export const get56 = oc +export const get57 = oc .route({ deprecated: true, description: @@ -3629,7 +3683,7 @@ export const put5 = oc .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const agentComposer2 = { - get: get56, + get: get57, put: put5, candidates: candidates2, impact, @@ -3640,7 +3694,7 @@ export const agentComposer2 = { /** * Get last run result for draft workflow node */ -export const get57 = oc +export const get58 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3653,7 +3707,7 @@ export const get57 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get57, + get: get58, } /** @@ -3665,7 +3719,7 @@ export const lastRun = { * * @deprecated */ -export const post50 = oc +export const post51 = oc .route({ deprecated: true, description: @@ -3686,7 +3740,7 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post50, + post: post51, } /** @@ -3698,7 +3752,7 @@ export const run8 = { * * @deprecated */ -export const post51 = oc +export const post52 = oc .route({ deprecated: true, description: @@ -3714,7 +3768,7 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post51, + post: post52, } export const trigger = { @@ -3744,7 +3798,7 @@ export const delete8 = oc * * @deprecated */ -export const get58 = oc +export const get59 = oc .route({ deprecated: true, description: @@ -3760,7 +3814,7 @@ export const get58 = oc export const variables = { delete: delete8, - get: get58, + get: get59, } export const byNodeId7 = { @@ -3784,7 +3838,7 @@ export const nodes7 = { * * @deprecated */ -export const post52 = oc +export const post53 = oc .route({ deprecated: true, description: @@ -3805,7 +3859,7 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post52, + post: post53, } /** @@ -3815,7 +3869,7 @@ export const run10 = { * * @deprecated */ -export const get59 = oc +export const get60 = oc .route({ deprecated: true, description: @@ -3830,7 +3884,7 @@ export const get59 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get59, + get: get60, } /** @@ -3840,7 +3894,7 @@ export const events = { * * @deprecated */ -export const get60 = oc +export const get61 = oc .route({ deprecated: true, description: @@ -3859,7 +3913,7 @@ export const get60 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) export const preview3 = { - get: get60, + get: get61, } export const byOutputName = { @@ -3873,7 +3927,7 @@ export const byOutputName = { * * @deprecated */ -export const get61 = oc +export const get62 = oc .route({ deprecated: true, description: @@ -3888,7 +3942,7 @@ export const get61 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId8 = { - get: get61, + get: get62, byOutputName, } @@ -3899,7 +3953,7 @@ export const byNodeId8 = { * * @deprecated */ -export const get62 = oc +export const get63 = oc .route({ deprecated: true, description: @@ -3914,7 +3968,7 @@ export const get62 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get62, + get: get63, events, byNodeId: byNodeId8, } @@ -3934,7 +3988,7 @@ export const runs = { * * @deprecated */ -export const get63 = oc +export const get64 = oc .route({ deprecated: true, description: @@ -3949,7 +4003,7 @@ export const get63 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get63, + get: get64, } /** @@ -3961,7 +4015,7 @@ export const systemVariables = { * * @deprecated */ -export const post53 = oc +export const post54 = oc .route({ deprecated: true, description: @@ -3982,7 +4036,7 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post53, + post: post54, } /** @@ -3994,7 +4048,7 @@ export const run11 = { * * @deprecated */ -export const post54 = oc +export const post55 = oc .route({ deprecated: true, description: @@ -4015,7 +4069,7 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post54, + post: post55, } export const trigger2 = { @@ -4071,7 +4125,7 @@ export const delete9 = oc * * @deprecated */ -export const get64 = oc +export const get65 = oc .route({ deprecated: true, description: @@ -4113,7 +4167,7 @@ export const patch2 = oc export const byVariableId = { delete: delete9, - get: get64, + get: get65, patch: patch2, reset, } @@ -4143,7 +4197,7 @@ export const delete10 = oc * * @deprecated */ -export const get65 = oc +export const get66 = oc .route({ deprecated: true, description: @@ -4165,7 +4219,7 @@ export const get65 = oc export const variables2 = { delete: delete10, - get: get65, + get: get66, byVariableId, } @@ -4178,7 +4232,7 @@ export const variables2 = { * * @deprecated */ -export const get66 = oc +export const get67 = oc .route({ deprecated: true, description: @@ -4202,7 +4256,7 @@ export const get66 = oc * * @deprecated */ -export const post55 = oc +export const post56 = oc .route({ deprecated: true, description: @@ -4223,8 +4277,8 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get66, - post: post55, + get: get67, + post: post56, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4248,7 +4302,7 @@ export const draft2 = { * * @deprecated */ -export const get67 = oc +export const get68 = oc .route({ deprecated: true, description: @@ -4270,7 +4324,7 @@ export const get67 = oc * * @deprecated */ -export const post56 = oc +export const post57 = oc .route({ deprecated: true, description: @@ -4291,8 +4345,8 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get67, - post: post56, + get: get68, + post: post57, } /** @@ -4302,7 +4356,7 @@ export const publish = { * * @deprecated */ -export const get68 = oc +export const get69 = oc .route({ deprecated: true, description: @@ -4317,7 +4371,7 @@ export const get68 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get68, + get: get69, } /** @@ -4327,7 +4381,7 @@ export const events2 = { * * @deprecated */ -export const get69 = oc +export const get70 = oc .route({ deprecated: true, description: @@ -4350,7 +4404,7 @@ export const get69 = oc ) export const preview4 = { - get: get69, + get: get70, } export const byOutputName2 = { @@ -4364,7 +4418,7 @@ export const byOutputName2 = { * * @deprecated */ -export const get70 = oc +export const get71 = oc .route({ deprecated: true, description: @@ -4379,7 +4433,7 @@ export const get70 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId9 = { - get: get70, + get: get71, byOutputName: byOutputName2, } @@ -4390,7 +4444,7 @@ export const byNodeId9 = { * * @deprecated */ -export const get71 = oc +export const get72 = oc .route({ deprecated: true, description: @@ -4405,7 +4459,7 @@ export const get71 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get71, + get: get72, events: events2, byNodeId: byNodeId9, } @@ -4429,7 +4483,7 @@ export const published = { * * @deprecated */ -export const get72 = oc +export const get73 = oc .route({ deprecated: true, description: @@ -4450,7 +4504,7 @@ export const get72 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get72, + get: get73, } export const triggers2 = { @@ -4464,7 +4518,7 @@ export const triggers2 = { * * @deprecated */ -export const post57 = oc +export const post58 = oc .route({ deprecated: true, description: @@ -4479,7 +4533,7 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post57, + post: post58, } /** @@ -4548,7 +4602,7 @@ export const byWorkflowId = { * * @deprecated */ -export const get73 = oc +export const get74 = oc .route({ deprecated: true, description: @@ -4569,7 +4623,7 @@ export const get73 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get73, + get: get74, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4606,7 +4660,7 @@ export const delete12 = oc * * @deprecated */ -export const get74 = oc +export const get75 = oc .route({ deprecated: true, description: @@ -4647,10 +4701,12 @@ export const put7 = oc export const byAppId2 = { delete: delete12, - get: get74, + get: get75, put: put7, advancedChat, agentComposer, + agentFeatures, + agentReferencingWorkflows, agent, annotationReply, annotationSetting, @@ -4716,7 +4772,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get75 = oc +export const get76 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4734,7 +4790,7 @@ export const get75 = oc * * Create a new API key for an app */ -export const post58 = oc +export const post59 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4749,8 +4805,8 @@ export const post58 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get75, - post: post58, + get: get76, + post: post59, byApiKeyId, } @@ -4765,7 +4821,7 @@ export const byResourceId = { * * @deprecated */ -export const get76 = oc +export const get77 = oc .route({ deprecated: true, description: @@ -4780,7 +4836,7 @@ export const get76 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get76, + get: get77, } export const server2 = { @@ -4796,7 +4852,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get77 = oc +export const get78 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4818,7 +4874,7 @@ export const get77 = oc * * @deprecated */ -export const post59 = oc +export const post60 = oc .route({ deprecated: true, description: @@ -4835,8 +4891,8 @@ export const post59 = oc .output(zPostAppsResponse) export const apps = { - get: get77, - post: post59, + get: get78, + post: post60, imports, workflows, byAppId: byAppId2, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 1b85de8add..7430914dd7 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -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 @@ -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 | 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 +} + export type AnnotationReplyPayload = { embedding_model_name: string embedding_provider_name: string @@ -295,10 +324,6 @@ export type SuggestedQuestionsResponse = { data: Array } -export type SimpleResultResponse = { - result: string -} - export type ConversationPagination = { has_next: boolean items: Array @@ -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 + 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 | 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: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 0a81aebc61..52d3c59c8a 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -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(), }) diff --git a/packages/contracts/generated/api/console/installed-apps/types.gen.ts b/packages/contracts/generated/api/console/installed-apps/types.gen.ts index 59120100bf..b1b08934c8 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -23,7 +23,7 @@ export type ChatMessagePayload = { inputs: { [key: string]: unknown } - model_config: { + model_config?: { [key: string]: unknown } parent_message_id?: string | null diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index aa5feb1650..6fd4856c93 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -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'), diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 194ec6f363..197c35d16e 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -86,6 +86,7 @@ export type AppListRow = { export type AppMode = | 'advanced-chat' + | 'agent' | 'agent-chat' | 'channel' | 'chat' diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index a98f0e3a86..cd2072b8ef 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -28,6 +28,7 @@ export const zAppDescribeQuery = z.object({ */ export const zAppMode = z.enum([ 'advanced-chat', + 'agent', 'agent-chat', 'channel', 'chat',