mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
chore(agent-v2): sync changes (#38442)
Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: zyssyz123 <916125788@qq.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com> Co-authored-by: 盐粒 Yanli <mail@yanli.one>
This commit is contained in:
co-authored by
Joel
zyssyz123
autofix-ci[bot]
林玮
盐粒 Yanli
parent
bdb3469ca0
commit
d0ea5a5e0d
@@ -0,0 +1,52 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from extensions.ext_database import db
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App
|
||||
|
||||
|
||||
def get_published_agent_app_feature_dict_and_user_input_form(
|
||||
app_model: App,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Return public Agent App parameters backed by the published Agent Soul."""
|
||||
app_model_config = app_model.app_model_config
|
||||
|
||||
agent_id = app_model.bound_agent_id
|
||||
if not agent_id:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
if not agent.active_config_snapshot_id or not agent.active_config_is_published:
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
|
||||
snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot)
|
||||
.where(
|
||||
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent.id,
|
||||
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if snapshot is None:
|
||||
raise AgentAppGeneratorError("Agent published version not found")
|
||||
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
features_dict = merge_agent_app_features(agent_soul=agent_soul, app_model_config=app_model_config)
|
||||
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
|
||||
@@ -108,14 +108,8 @@ class SandboxReadResponse(ResponseModel):
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class SandboxToolFileResponse(ResponseModel):
|
||||
transfer_method: Literal["tool_file"] = "tool_file"
|
||||
reference: str
|
||||
|
||||
|
||||
class SandboxUploadResponse(ResponseModel):
|
||||
path: str
|
||||
file: SandboxToolFileResponse
|
||||
url: str
|
||||
|
||||
|
||||
register_schema_models(
|
||||
@@ -225,7 +219,7 @@ class AgentAppSandboxReadResource(Resource):
|
||||
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
|
||||
class AgentAppSandboxUploadResource(Resource):
|
||||
@console_ns.doc("upload_agent_app_sandbox_file")
|
||||
@console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping")
|
||||
@console_ns.doc(description="Upload one Agent App sandbox file and return a signed download URL")
|
||||
@console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__])
|
||||
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
|
||||
@setup_required
|
||||
@@ -322,7 +316,7 @@ class WorkflowAgentSandboxReadResource(Resource):
|
||||
)
|
||||
class WorkflowAgentSandboxUploadResource(Resource):
|
||||
@console_ns.doc("upload_workflow_agent_sandbox_file")
|
||||
@console_ns.doc(description="Upload one workflow Agent sandbox file as a Dify ToolFile mapping")
|
||||
@console_ns.doc(description="Upload one workflow Agent sandbox file and return a signed download URL")
|
||||
@console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__])
|
||||
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
|
||||
@setup_required
|
||||
|
||||
@@ -124,6 +124,7 @@ Use only the current Build chat message history to identify changes that need to
|
||||
validate old config unless the message history already shows that the old config is invalid.
|
||||
|
||||
Only update the build-draft config note when the current Build chat contains durable context that later runs need.
|
||||
Write the config note in the language used by the message history.
|
||||
Do not create, update, delete, inspect, or fill gaps in other Agent config resources, including config files, config
|
||||
skills, config env, tools, models, knowledge, or prompt settings.
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ class OpenApiErrorCode(StrEnum):
|
||||
# domain codes (must match the error_code attribute of the exception
|
||||
# classes raised on the openapi surface)
|
||||
APP_UNAVAILABLE = "app_unavailable"
|
||||
AGENT_NOT_PUBLISHED = "agent_not_published"
|
||||
CONVERSATION_COMPLETED = "conversation_completed"
|
||||
PROVIDER_NOT_INITIALIZE = "provider_not_initialize"
|
||||
PROVIDER_QUOTA_EXCEEDED = "provider_quota_exceeded"
|
||||
|
||||
@@ -2,19 +2,16 @@ from typing import Any, cast
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
|
||||
from controllers.common.fields import Parameters
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError
|
||||
from controllers.service_api.wraps import validate_app_token
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
|
||||
from extensions.ext_database import db
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from fields.base import ResponseModel
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, AppMode
|
||||
from services.app_service import AppService
|
||||
|
||||
@@ -35,38 +32,13 @@ register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, App
|
||||
|
||||
|
||||
def _get_agent_app_feature_dict_and_user_input_form(app_model: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
app_model_config = app_model.app_model_config
|
||||
features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {}
|
||||
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
Agent.app_id == app_model.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
try:
|
||||
return get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
except AgentAppNotPublishedError:
|
||||
raise AgentNotPublishedError()
|
||||
except AgentAppGeneratorError:
|
||||
raise AppUnavailableError()
|
||||
|
||||
snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot)
|
||||
.where(
|
||||
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent.id,
|
||||
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if snapshot is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
|
||||
|
||||
|
||||
@service_api_ns.route("/parameters")
|
||||
class AppParameterApi(Resource):
|
||||
|
||||
@@ -15,6 +15,7 @@ from controllers.common.schema import register_response_schema_models, register_
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.app.error import (
|
||||
AgentNotPublishedError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ConversationCompletedError,
|
||||
@@ -31,6 +32,7 @@ from controllers.service_api.schema import (
|
||||
)
|
||||
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import (
|
||||
ModelCurrentlyNotSupportError,
|
||||
@@ -248,6 +250,8 @@ class CompletionApi(Resource):
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except AgentAppNotPublishedError:
|
||||
raise AgentNotPublishedError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
@@ -403,6 +407,8 @@ class ChatApi(Resource):
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except AgentAppNotPublishedError:
|
||||
raise AgentNotPublishedError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
|
||||
@@ -7,6 +7,12 @@ class AppUnavailableError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class AgentNotPublishedError(BaseHTTPException):
|
||||
error_code = "agent_not_published"
|
||||
description = "Agent has not been published. Please publish the Agent before using the API."
|
||||
code = 400
|
||||
|
||||
|
||||
class NotCompletionAppError(BaseHTTPException):
|
||||
error_code = "not_completion_app"
|
||||
description = "Please check if your Completion app mode matches the right API route."
|
||||
|
||||
@@ -8,8 +8,10 @@ from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.common import fields
|
||||
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_webapp_passport
|
||||
from models.model import App, AppMode, EndUser
|
||||
@@ -19,7 +21,7 @@ from services.feature_service import FeatureService
|
||||
from services.webapp_auth_service import WebAppAuthService
|
||||
|
||||
from . import web_ns
|
||||
from .error import AppUnavailableError
|
||||
from .error import AgentNotPublishedError, AppUnavailableError
|
||||
from .wraps import WebApiResource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -74,12 +76,21 @@ class AppParameterApi(WebApiResource):
|
||||
@web_ns.response(200, "Success", web_ns.models[fields.Parameters.__name__])
|
||||
def get(self, app_model: App, end_user: EndUser):
|
||||
"""Retrieve app parameters."""
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
features_dict: dict[str, Any]
|
||||
user_input_form: list[dict[str, Any]]
|
||||
if app_model.mode == AppMode.AGENT:
|
||||
try:
|
||||
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
except AgentAppNotPublishedError:
|
||||
raise AgentNotPublishedError()
|
||||
except AgentAppGeneratorError:
|
||||
raise AppUnavailableError()
|
||||
elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow
|
||||
if workflow is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
features_dict: dict[str, Any] = workflow.features_dict
|
||||
features_dict = workflow.features_dict
|
||||
user_input_form = workflow.user_input_form(to_old_structure=True)
|
||||
else:
|
||||
app_model_config = app_model.app_model_config
|
||||
|
||||
@@ -11,6 +11,7 @@ from controllers.common.schema import register_response_schema_models, register_
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import (
|
||||
AgentNotPublishedError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ConversationCompletedError,
|
||||
@@ -22,6 +23,7 @@ from controllers.web.error import (
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import (
|
||||
ModelCurrentlyNotSupportError,
|
||||
@@ -138,6 +140,8 @@ class CompletionApi(WebApiResource):
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except AgentAppNotPublishedError:
|
||||
raise AgentNotPublishedError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
@@ -235,6 +239,8 @@ class ChatApi(WebApiResource):
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except AgentAppNotPublishedError:
|
||||
raise AgentNotPublishedError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
|
||||
@@ -7,6 +7,12 @@ class AppUnavailableError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class AgentNotPublishedError(BaseHTTPException):
|
||||
error_code = "agent_not_published"
|
||||
description = "Agent has not been published. Please publish the Agent before using the web app."
|
||||
code = 400
|
||||
|
||||
|
||||
class NotCompletionAppError(BaseHTTPException):
|
||||
error_code = "not_completion_app"
|
||||
description = "Please check if your Completion app mode matches the right API route."
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
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.
|
||||
prompt) plus app-level feature flags from Agent Soul, while preserving any
|
||||
legacy ``app_model_config`` feature flags when present. Then we reuse the same
|
||||
sub-managers the chat app type uses.
|
||||
"""
|
||||
|
||||
from typing import Any, cast
|
||||
@@ -21,6 +21,7 @@ from core.app.app_config.entities import (
|
||||
EasyUIBasedAppModelConfigFrom,
|
||||
PromptTemplateEntity,
|
||||
)
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, Conversation
|
||||
@@ -79,12 +80,11 @@ class AgentAppConfigManager(BaseAppConfigManager):
|
||||
) -> 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
|
||||
Feature flags come from Agent Soul and fill gaps in the legacy
|
||||
``app_model_config`` when one exists; 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 {}
|
||||
base = merge_agent_app_features(agent_soul=agent_soul, app_model_config=app_model_config)
|
||||
|
||||
model = agent_soul.model
|
||||
if model is not None:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from typing import Any
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def merge_agent_app_features(
|
||||
*,
|
||||
agent_soul: AgentSoulConfig,
|
||||
app_model_config: Any | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Project public Agent App features from legacy config plus Agent Soul.
|
||||
|
||||
The hidden backing app may still carry legacy presentation fields such as
|
||||
opening statements. Agent Soul is the source of truth for Agent-owned
|
||||
features like file upload, so Soul fields override same-named legacy keys.
|
||||
"""
|
||||
features: dict[str, Any] = dict(app_model_config.to_dict()) if app_model_config else {}
|
||||
soul_features = agent_soul.app_features.model_dump(mode="json", exclude_none=True)
|
||||
features.update(soul_features)
|
||||
return features
|
||||
|
||||
|
||||
__all__ = ["merge_agent_app_features"]
|
||||
@@ -32,6 +32,7 @@ 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.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
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
|
||||
@@ -64,10 +65,6 @@ from services.conversation_service import ConversationService
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentAppGeneratorError(ValueError):
|
||||
"""Raised when an Agent App turn cannot be set up."""
|
||||
|
||||
|
||||
def _append_prompt_file_mappings(query: str, prompt_file_mappings: Sequence[JsonValue]) -> str:
|
||||
"""Append raw request file references to the backend user prompt."""
|
||||
if not prompt_file_mappings:
|
||||
@@ -614,6 +611,8 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
"build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft"
|
||||
)
|
||||
return agent, draft.id, config_version_kind, agent_soul
|
||||
if not agent.active_config_snapshot_id or not agent.active_config_is_published:
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
_, snapshot, agent_soul = self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent.id,
|
||||
@@ -709,4 +708,4 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
return agent, draft, agent_soul
|
||||
|
||||
|
||||
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
|
||||
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError", "AgentAppNotPublishedError"]
|
||||
|
||||
@@ -372,14 +372,14 @@ class _AgentProcessRecorder:
|
||||
row = MessageAgentThought(
|
||||
message_id=self._message_id,
|
||||
message_chain_id=None,
|
||||
thought=thought,
|
||||
tool=tool,
|
||||
thought=thought or "",
|
||||
tool=tool or "",
|
||||
tool_labels_str=_tool_labels(tool),
|
||||
tool_meta_str="{}",
|
||||
tool_input=tool_input,
|
||||
observation=None,
|
||||
tool_input=tool_input or "",
|
||||
observation="",
|
||||
tool_process_data=None,
|
||||
message=None,
|
||||
message="",
|
||||
message_token=0,
|
||||
message_unit_price=Decimal(0),
|
||||
message_price_unit=Decimal("0.001"),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class AgentAppGeneratorError(ValueError):
|
||||
"""Raised when an Agent App turn cannot be set up."""
|
||||
|
||||
|
||||
class AgentAppNotPublishedError(AgentAppGeneratorError):
|
||||
"""Raised when a public Agent App runtime is requested before publish."""
|
||||
@@ -122,7 +122,7 @@ class MessageStreamResponse(StreamResponse):
|
||||
event: StreamEvent = StreamEvent.MESSAGE
|
||||
id: str
|
||||
answer: str
|
||||
from_variable_selector: list[str] | None = None
|
||||
from_variable_selector: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MessageAudioStreamResponse(StreamResponse):
|
||||
@@ -151,7 +151,7 @@ class MessageEndStreamResponse(StreamResponse):
|
||||
event: StreamEvent = StreamEvent.MESSAGE_END
|
||||
id: str
|
||||
metadata: Mapping[str, object] = Field(default_factory=dict)
|
||||
files: Sequence[Mapping[str, Any]] | None = None
|
||||
files: Sequence[Mapping[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MessageFileStreamResponse(StreamResponse):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from threading import Thread
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -44,7 +44,7 @@ from core.app.entities.task_entities import (
|
||||
)
|
||||
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
from core.app.task_pipeline.message_file_utils import prepare_file_dict
|
||||
from core.app.task_pipeline.message_file_utils import MessageFileInfoDict, prepare_file_dict
|
||||
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
|
||||
from core.model_manager import ModelInstance
|
||||
from core.ops.entities.trace_entity import TraceTaskName
|
||||
@@ -466,10 +466,10 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
:return:
|
||||
"""
|
||||
self._task_state.metadata.usage = self._task_state.llm_result.usage
|
||||
metadata_dict = self._task_state.metadata.model_dump()
|
||||
metadata_dict = self._task_state.metadata.model_dump(exclude_none=True)
|
||||
|
||||
# Fetch files associated with this message
|
||||
files = None
|
||||
files: list[MessageFileInfoDict] = []
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
message_files = session.scalars(select(MessageFile).where(MessageFile.message_id == self._message_id)).all()
|
||||
|
||||
@@ -492,13 +492,13 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
file_dict = prepare_file_dict(message_file, upload_files_map)
|
||||
files_list.append(file_dict)
|
||||
|
||||
files = files_list or None
|
||||
files = files_list
|
||||
|
||||
return MessageEndStreamResponse(
|
||||
task_id=self._application_generate_entity.task_id,
|
||||
id=self._message_id,
|
||||
metadata=metadata_dict,
|
||||
files=files,
|
||||
files=cast(Sequence[Mapping[str, Any]], files),
|
||||
)
|
||||
|
||||
def _agent_message_to_stream_response(self, answer: str, message_id: str) -> AgentMessageStreamResponse:
|
||||
@@ -528,11 +528,11 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
task_id=self._application_generate_entity.task_id,
|
||||
id=agent_thought.id,
|
||||
position=agent_thought.position,
|
||||
thought=agent_thought.thought,
|
||||
observation=agent_thought.observation,
|
||||
tool=agent_thought.tool,
|
||||
thought=agent_thought.thought or "",
|
||||
observation=agent_thought.observation or "",
|
||||
tool=agent_thought.tool or "",
|
||||
tool_labels=agent_thought.tool_labels,
|
||||
tool_input=agent_thought.tool_input,
|
||||
tool_input=agent_thought.tool_input or "",
|
||||
message_files=agent_thought.files,
|
||||
)
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ class MessageCycleManager:
|
||||
task_id=self._application_generate_entity.task_id,
|
||||
id=message_id,
|
||||
answer=answer,
|
||||
from_variable_selector=from_variable_selector,
|
||||
from_variable_selector=from_variable_selector or [],
|
||||
event=event_type or StreamEvent.MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ from clients.agent_backend import (
|
||||
)
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value
|
||||
from graphon.file import File, FileTransferMethod
|
||||
from graphon.variables.segments import Segment
|
||||
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
|
||||
@@ -354,17 +354,22 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
query = get_system_text(context.variable_pool, SystemVariableKey.QUERY)
|
||||
uploaded_files = self._summarize_uploaded_workflow_files(context.variable_pool)
|
||||
resolved_outputs = self._resolve_previous_node_outputs(
|
||||
context.variable_pool,
|
||||
node_job.previous_node_output_refs,
|
||||
)
|
||||
if not query and not resolved_outputs:
|
||||
if not query and uploaded_files is None and not resolved_outputs:
|
||||
return ""
|
||||
|
||||
lines.append("Workflow context loaded for this run:")
|
||||
if query:
|
||||
lines.append(f"- User query: {query}")
|
||||
|
||||
if uploaded_files is not None:
|
||||
lines.append("- Uploaded workflow files:")
|
||||
lines.append(f" - sys.files: {uploaded_files}")
|
||||
|
||||
if resolved_outputs:
|
||||
lines.append("- Previous node outputs:")
|
||||
for item in resolved_outputs:
|
||||
@@ -373,6 +378,14 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _summarize_uploaded_workflow_files(self, variable_pool: VariablePoolReader) -> str | None:
|
||||
files = get_system_value(variable_pool, SystemVariableKey.FILES)
|
||||
if files is None:
|
||||
return None
|
||||
if isinstance(files, list | tuple) and not files:
|
||||
return None
|
||||
return self._summarize_value(files)
|
||||
|
||||
def _build_workflow_task_prompt(
|
||||
self,
|
||||
context: WorkflowAgentRuntimeBuildContext,
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validat
|
||||
|
||||
from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator
|
||||
from core.workflow.file_reference import is_canonical_file_reference
|
||||
from graphon.file import FileTransferMethod
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
|
||||
|
||||
class AgentKnowledgeQueryMode(StrEnum):
|
||||
@@ -314,8 +314,9 @@ class AgentKnowledgeQueryConfig(BaseModel):
|
||||
|
||||
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
|
||||
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
|
||||
set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
``value`` while ``generated_query`` leaves that value empty.
|
||||
set owns its own query policy. Mode-dependent completeness, such as
|
||||
requiring ``value`` for ``user_query``, is enforced by composer publish
|
||||
validation so draft saves can persist partially configured knowledge sets.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -323,12 +324,6 @@ class AgentKnowledgeQueryConfig(BaseModel):
|
||||
mode: AgentKnowledgeQueryMode
|
||||
value: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query(self) -> Self:
|
||||
if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip():
|
||||
raise ValueError("knowledge query.value is required for user_query mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeModelConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -356,8 +351,9 @@ class AgentKnowledgeRetrievalConfig(BaseModel):
|
||||
"""Per-set retrieval policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Retrieval settings now live on each knowledge set instead of one shared
|
||||
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
|
||||
``single`` retrieval with a required model config.
|
||||
flat config. Mode-dependent completeness, such as requiring ``top_k`` for
|
||||
``multiple`` or a model for ``single``, is enforced by composer publish
|
||||
validation so draft saves can persist partially configured knowledge sets.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -371,14 +367,6 @@ class AgentKnowledgeRetrievalConfig(BaseModel):
|
||||
weights: AgentKnowledgeWeightedScoreConfig | None = None
|
||||
model: AgentKnowledgeModelConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_fields(self) -> Self:
|
||||
if self.mode == "multiple" and self.top_k is None:
|
||||
raise ValueError("knowledge retrieval.top_k is required for multiple mode")
|
||||
if self.mode == "single" and self.model is None:
|
||||
raise ValueError("knowledge retrieval.model is required for single mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataCondition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -401,6 +389,8 @@ class AgentKnowledgeMetadataFilteringConfig(BaseModel):
|
||||
The Python attribute uses ``metadata_model_config`` for clarity because the
|
||||
model belongs to metadata filtering specifically, while the external API and
|
||||
generated schema keep the historical ``model_config`` field name via alias.
|
||||
Mode-dependent completeness is enforced by composer publish validation so
|
||||
draft saves can persist partially configured metadata filters.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
@@ -410,14 +400,6 @@ class AgentKnowledgeMetadataFilteringConfig(BaseModel):
|
||||
metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config")
|
||||
conditions: AgentKnowledgeMetadataConditions | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_fields(self) -> Self:
|
||||
if self.mode == "automatic" and self.metadata_model_config is None:
|
||||
raise ValueError("metadata_filtering.model_config is required for automatic mode")
|
||||
if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions):
|
||||
raise ValueError("metadata_filtering.conditions is required for manual mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeSetConfig(BaseModel):
|
||||
"""One explicit knowledge set in Agent v2.
|
||||
@@ -547,6 +529,23 @@ class AgentSensitiveWordAvoidanceFeatureConfig(AgentFeatureToggleConfig):
|
||||
config: AgentModerationProviderConfig | None = None
|
||||
|
||||
|
||||
class AgentFileUploadImageFeatureConfig(AgentFeatureToggleConfig):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class AgentFileUploadFeatureConfig(AgentFeatureToggleConfig):
|
||||
enabled: bool = True
|
||||
allowed_file_extensions: list[str] = Field(default_factory=lambda: ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"])
|
||||
allowed_file_types: list[FileType] = Field(
|
||||
default_factory=lambda: [FileType.DOCUMENT, FileType.IMAGE, FileType.AUDIO, FileType.VIDEO]
|
||||
)
|
||||
allowed_file_upload_methods: list[FileTransferMethod] = Field(
|
||||
default_factory=lambda: [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL]
|
||||
)
|
||||
image: AgentFileUploadImageFeatureConfig = Field(default_factory=AgentFileUploadImageFeatureConfig)
|
||||
number_limits: int = 3
|
||||
|
||||
|
||||
class AgentSoulAppFeaturesConfig(AgentFlexibleConfig):
|
||||
opening_statement: str | None = None
|
||||
suggested_questions: list[str] | None = None
|
||||
@@ -555,6 +554,7 @@ class AgentSoulAppFeaturesConfig(AgentFlexibleConfig):
|
||||
text_to_speech: AgentTextToSpeechFeatureConfig | None = None
|
||||
retriever_resource: AgentFeatureToggleConfig | None = None
|
||||
sensitive_word_avoidance: AgentSensitiveWordAvoidanceFeatureConfig | None = None
|
||||
file_upload: AgentFileUploadFeatureConfig = Field(default_factory=AgentFileUploadFeatureConfig)
|
||||
|
||||
|
||||
class WorkflowPreviousNodeOutputRef(AgentFlexibleConfig):
|
||||
|
||||
@@ -1268,7 +1268,7 @@ Read a text/binary preview file in an Agent App conversation sandbox
|
||||
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
|
||||
|
||||
### [POST] /agent/{agent_id}/sandbox/files/upload
|
||||
Upload one Agent App sandbox file as a Dify ToolFile mapping
|
||||
Upload one Agent App sandbox file and return a signed download URL
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -3777,7 +3777,7 @@ Read a text/binary preview file in a workflow Agent node sandbox
|
||||
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload
|
||||
Upload one workflow Agent sandbox file as a Dify ToolFile mapping
|
||||
Upload one workflow Agent sandbox file and return a signed download URL
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -13754,6 +13754,23 @@ Stable Agent Soul reference to one normalized skill archive.
|
||||
| upload_file_id | string | | No |
|
||||
| url | string | | No |
|
||||
|
||||
#### AgentFileUploadFeatureConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| allowed_file_extensions | [ string ] | | No |
|
||||
| allowed_file_types | [ [FileType](#filetype) ] | | No |
|
||||
| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | No |
|
||||
| enabled | boolean, <br>**Default:** true | | No |
|
||||
| image | [AgentFileUploadImageFeatureConfig](#agentfileuploadimagefeatureconfig) | | No |
|
||||
| number_limits | integer, <br>**Default:** 3 | | No |
|
||||
|
||||
#### AgentFileUploadImageFeatureConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| enabled | boolean, <br>**Default:** true | | No |
|
||||
|
||||
#### AgentHumanContactConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13897,6 +13914,8 @@ Per-set metadata filtering policy.
|
||||
The Python attribute uses ``metadata_model_config`` for clarity because the
|
||||
model belongs to metadata filtering specifically, while the external API and
|
||||
generated schema keep the historical ``model_config`` field name via alias.
|
||||
Mode-dependent completeness is enforced by composer publish validation so
|
||||
draft saves can persist partially configured metadata filters.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
@@ -13919,8 +13938,9 @@ Per-set query policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
|
||||
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
|
||||
set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
``value`` while ``generated_query`` leaves that value empty.
|
||||
set owns its own query policy. Mode-dependent completeness, such as
|
||||
requiring ``value`` for ``user_query``, is enforced by composer publish
|
||||
validation so draft saves can persist partially configured knowledge sets.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
@@ -13945,8 +13965,9 @@ set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
Per-set retrieval policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Retrieval settings now live on each knowledge set instead of one shared
|
||||
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
|
||||
``single`` retrieval with a required model config.
|
||||
flat config. Mode-dependent completeness, such as requiring ``top_k`` for
|
||||
``multiple`` or a model for ``single``, is enforced by composer publish
|
||||
validation so draft saves can persist partially configured knowledge sets.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
@@ -14345,6 +14366,7 @@ Visibility and lifecycle scope of an Agent record.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| file_upload | [AgentFileUploadFeatureConfig](#agentfileuploadfeatureconfig) | | No |
|
||||
| opening_statement | string | | No |
|
||||
| retriever_resource | [AgentFeatureToggleConfig](#agentfeaturetoggleconfig) | | No |
|
||||
| sensitive_word_avoidance | [AgentSensitiveWordAvoidanceFeatureConfig](#agentsensitivewordavoidancefeatureconfig) | | No |
|
||||
@@ -20523,19 +20545,11 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
|
||||
| text | string | | No |
|
||||
| truncated | boolean | | Yes |
|
||||
|
||||
#### SandboxToolFileResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| reference | string | | Yes |
|
||||
| transfer_method | string, <br>**Default:** tool_file | | No |
|
||||
|
||||
#### SandboxUploadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| file | [SandboxToolFileResponse](#sandboxtoolfileresponse) | | Yes |
|
||||
| path | string | | Yes |
|
||||
| url | string | | Yes |
|
||||
|
||||
#### SavedMessageCreatePayload
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from models.workflow import Workflow
|
||||
from services.agent.agent_soul_state import agent_soul_has_model
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.errors import (
|
||||
AgentModelNotConfiguredError,
|
||||
AgentNameConflictError,
|
||||
AgentNotFoundError,
|
||||
AgentVersionConflictError,
|
||||
@@ -168,7 +169,8 @@ class AgentComposerService:
|
||||
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
if payload.save_strategy in _PUBLISH_SAVE_STRATEGIES:
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
|
||||
@@ -357,7 +359,6 @@ class AgentComposerService:
|
||||
raise ValueError("agent_soul is required")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
|
||||
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
if not agent:
|
||||
@@ -401,7 +402,6 @@ class AgentComposerService:
|
||||
raise ValueError("agent_soul is required")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return cls._save_agent_composer_for_agent(
|
||||
tenant_id=tenant_id,
|
||||
@@ -511,6 +511,8 @@ class AgentComposerService:
|
||||
version_note=version_note,
|
||||
)
|
||||
)
|
||||
if not agent_soul_has_model(agent_soul):
|
||||
raise AgentModelNotConfiguredError()
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul)
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
@@ -591,7 +593,6 @@ class AgentComposerService:
|
||||
raise ValueError("agent_soul is required")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
ComposerConfigValidator.validate_draft_save_payload(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
build_draft = cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from models.agent_config_entities import AgentKnowledgeQueryMode
|
||||
from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError
|
||||
from services.agent.prompt_mentions import (
|
||||
MAX_MENTIONS_PER_PROMPT,
|
||||
@@ -228,9 +229,40 @@ class ComposerConfigValidator:
|
||||
@classmethod
|
||||
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:
|
||||
dumped = agent_soul.model_dump(mode="json")
|
||||
cls._validate_knowledge_runtime_config(agent_soul)
|
||||
cls._reject_plaintext_secrets(dumped, path="agent_soul")
|
||||
cls._validate_shell_config(dumped)
|
||||
|
||||
@classmethod
|
||||
def _validate_knowledge_runtime_config(cls, agent_soul: AgentSoulConfig) -> None:
|
||||
"""Validate knowledge settings that are required only for publish/run.
|
||||
|
||||
Draft composer saves must be able to persist partially configured
|
||||
knowledge sets while a user is still editing the panel. These checks
|
||||
stay in the publish validator so invalid runtime configs are still
|
||||
blocked before a version can be published or executed.
|
||||
"""
|
||||
for knowledge_set in agent_soul.knowledge.sets:
|
||||
if (
|
||||
knowledge_set.query.mode == AgentKnowledgeQueryMode.USER_QUERY
|
||||
and not (knowledge_set.query.value or "").strip()
|
||||
):
|
||||
raise InvalidComposerConfigError("knowledge query.value is required for user_query mode")
|
||||
|
||||
retrieval = knowledge_set.retrieval
|
||||
if retrieval.mode == "multiple" and retrieval.top_k is None:
|
||||
raise InvalidComposerConfigError("knowledge retrieval.top_k is required for multiple mode")
|
||||
if retrieval.mode == "single" and retrieval.model is None:
|
||||
raise InvalidComposerConfigError("knowledge retrieval.model is required for single mode")
|
||||
|
||||
metadata_filtering = knowledge_set.metadata_filtering
|
||||
if metadata_filtering.mode == "automatic" and metadata_filtering.metadata_model_config is None:
|
||||
raise InvalidComposerConfigError("metadata_filtering.model_config is required for automatic mode")
|
||||
if metadata_filtering.mode == "manual" and (
|
||||
metadata_filtering.conditions is None or not metadata_filtering.conditions.conditions
|
||||
):
|
||||
raise InvalidComposerConfigError("metadata_filtering.conditions is required for manual mode")
|
||||
|
||||
@classmethod
|
||||
def validate_node_job(cls, node_job: WorkflowNodeJobConfig) -> None:
|
||||
cls._reject_plaintext_secrets(node_job.model_dump(mode="json"), path="node_job")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from werkzeug.exceptions import BadRequest, Conflict, NotFound
|
||||
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
|
||||
class AgentNotFoundError(NotFound):
|
||||
description = "Agent not found."
|
||||
@@ -21,6 +23,12 @@ class AgentVersionConflictError(Conflict):
|
||||
description = "Agent config version changed. Please reload and try again."
|
||||
|
||||
|
||||
class AgentModelNotConfiguredError(BaseHTTPException):
|
||||
error_code = "agent_model_not_configured"
|
||||
description = "Agent App requires the Agent Soul model to be configured."
|
||||
code = 400
|
||||
|
||||
|
||||
class AgentSoulLockedError(BadRequest):
|
||||
description = "Agent Soul is locked for this workflow node."
|
||||
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
These services keep product-facing locators (conversation, workflow run, node)
|
||||
on the API boundary and translate them into the agent backend's
|
||||
``SandboxLocator`` using persisted non-sensitive runtime layer specs plus the
|
||||
saved Agenton session snapshot.
|
||||
saved Agenton session snapshot. Upload responses stay console-facing here: the
|
||||
agent backend still returns a canonical ToolFile mapping, while this API layer
|
||||
re-resolves that mapping into a signed browser download URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.client import Client
|
||||
@@ -18,7 +22,10 @@ from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
|
||||
from core.db.session_factory import session_factory
|
||||
from factories import file_factory
|
||||
from models.agent import AgentRuntimeSessionOwnerType, WorkflowAgentRuntimeSession, WorkflowAgentRuntimeSessionStatus
|
||||
|
||||
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
|
||||
@@ -45,6 +52,12 @@ class AgentSandboxInfo(BaseModel):
|
||||
workspace_cwd: str
|
||||
|
||||
|
||||
class AgentSandboxUploadDownload(BaseModel):
|
||||
"""Signed browser download URL for one sandbox upload result."""
|
||||
|
||||
url: str
|
||||
|
||||
|
||||
class AgentAppSandboxService:
|
||||
"""Inspect and proxy file access for an Agent App conversation sandbox."""
|
||||
|
||||
@@ -77,9 +90,15 @@ class AgentAppSandboxService:
|
||||
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
|
||||
return self._client_factory().read_sandbox_file_sync(locator, path)
|
||||
|
||||
def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
|
||||
def upload_file(
|
||||
self, *, tenant_id: str, app_id: str, conversation_id: str, path: str
|
||||
) -> AgentSandboxUploadDownload:
|
||||
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
|
||||
return self._client_factory().upload_sandbox_file_sync(locator, path)
|
||||
uploaded = self._client_factory().upload_sandbox_file_sync(locator, path)
|
||||
return _upload_download_response(
|
||||
tenant_id=tenant_id,
|
||||
file_mapping=uploaded.file.model_dump(mode="python"),
|
||||
)
|
||||
|
||||
def _resolve_locator(self, *, tenant_id: str, app_id: str, conversation_id: str) -> SandboxLocator:
|
||||
stored = self._session_store.load_active_session_for_conversation(
|
||||
@@ -153,7 +172,7 @@ class WorkflowAgentSandboxService:
|
||||
node_id: str,
|
||||
node_execution_id: str | None,
|
||||
path: str,
|
||||
):
|
||||
) -> AgentSandboxUploadDownload:
|
||||
locator = self._resolve_locator(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
@@ -161,7 +180,11 @@ class WorkflowAgentSandboxService:
|
||||
node_id=node_id,
|
||||
node_execution_id=node_execution_id,
|
||||
)
|
||||
return self._client_factory().upload_sandbox_file_sync(locator, path)
|
||||
uploaded = self._client_factory().upload_sandbox_file_sync(locator, path)
|
||||
return _upload_download_response(
|
||||
tenant_id=tenant_id,
|
||||
file_mapping=uploaded.file.model_dump(mode="python"),
|
||||
)
|
||||
|
||||
def _resolve_locator(
|
||||
self,
|
||||
@@ -246,6 +269,41 @@ def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec
|
||||
return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value)
|
||||
|
||||
|
||||
def _upload_download_response(*, tenant_id: str, file_mapping: dict[str, Any]) -> AgentSandboxUploadDownload:
|
||||
"""Resolve one uploaded ToolFile mapping into a signed external download URL."""
|
||||
|
||||
controller = DatabaseFileAccessController()
|
||||
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
|
||||
try:
|
||||
file = file_factory.build_from_mapping(
|
||||
mapping=file_mapping,
|
||||
tenant_id=tenant_id,
|
||||
access_controller=controller,
|
||||
)
|
||||
url = runtime.resolve_file_url(file=file, for_external=True)
|
||||
except ValueError as exc:
|
||||
raise AgentSandboxInspectorError(
|
||||
"sandbox_upload_download_unavailable",
|
||||
"uploaded sandbox file could not be converted to a download URL",
|
||||
status_code=502,
|
||||
) from exc
|
||||
|
||||
if not url:
|
||||
raise AgentSandboxInspectorError(
|
||||
"sandbox_upload_download_unavailable",
|
||||
"uploaded sandbox file does not support download URL generation",
|
||||
status_code=502,
|
||||
)
|
||||
return AgentSandboxUploadDownload(url=_with_as_attachment(url))
|
||||
|
||||
|
||||
def _with_as_attachment(url: str) -> str:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
||||
query.append(("as_attachment", "true"))
|
||||
return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query)))
|
||||
|
||||
|
||||
def _default_client_factory() -> Client:
|
||||
base_url = dify_config.AGENT_BACKEND_BASE_URL
|
||||
if not base_url:
|
||||
@@ -257,4 +315,10 @@ def _default_client_factory() -> Client:
|
||||
return Client(base_url=base_url)
|
||||
|
||||
|
||||
__all__ = ["AgentAppSandboxService", "AgentSandboxInfo", "AgentSandboxInspectorError", "WorkflowAgentSandboxService"]
|
||||
__all__ = [
|
||||
"AgentAppSandboxService",
|
||||
"AgentSandboxInfo",
|
||||
"AgentSandboxInspectorError",
|
||||
"AgentSandboxUploadDownload",
|
||||
"WorkflowAgentSandboxService",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from controllers.common import agent_app_parameters
|
||||
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_use_soul_file_upload(monkeypatch):
|
||||
app_model_config = SimpleNamespace(
|
||||
to_dict=lambda: {
|
||||
"opening_statement": "Hi from legacy presentation config",
|
||||
"file_upload": {
|
||||
"enabled": False,
|
||||
"image": {"enabled": False},
|
||||
},
|
||||
}
|
||||
)
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=app_model_config,
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
snapshot = SimpleNamespace(
|
||||
config_snapshot_dict={
|
||||
"app_features": {
|
||||
"file_upload": {
|
||||
"enabled": True,
|
||||
"allowed_file_extensions": ["PNG"],
|
||||
"allowed_file_types": ["image"],
|
||||
"allowed_file_upload_methods": ["local_file"],
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 2,
|
||||
}
|
||||
},
|
||||
"app_variables": [{"name": "topic", "type": "string", "required": True}],
|
||||
}
|
||||
)
|
||||
query_results = iter([agent, snapshot])
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results))
|
||||
|
||||
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
|
||||
assert parameters["opening_statement"] == "Hi from legacy presentation config"
|
||||
assert parameters["file_upload"] == {
|
||||
"enabled": True,
|
||||
"allowed_file_extensions": ["PNG"],
|
||||
"allowed_file_types": ["image"],
|
||||
"allowed_file_upload_methods": ["local_file"],
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 2,
|
||||
}
|
||||
assert parameters["user_input_form"] == [{"text-input": {"label": "topic", "variable": "topic", "required": True}}]
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_requires_bound_agent():
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id=None,
|
||||
app_model_config=None,
|
||||
)
|
||||
|
||||
with pytest.raises(AgentAppGeneratorError, match="no bound Agent"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_requires_existing_active_agent(monkeypatch):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=None,
|
||||
)
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: None)
|
||||
|
||||
with pytest.raises(AgentAppGeneratorError, match="no bound Agent"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("active_config_snapshot_id", "active_config_is_published"),
|
||||
[
|
||||
(None, True),
|
||||
("snapshot-1", False),
|
||||
],
|
||||
)
|
||||
def test_published_agent_app_parameters_requires_published_agent(
|
||||
monkeypatch, active_config_snapshot_id, active_config_is_published
|
||||
):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=None,
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id=active_config_snapshot_id,
|
||||
active_config_is_published=active_config_is_published,
|
||||
)
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: agent)
|
||||
|
||||
with pytest.raises(AgentAppNotPublishedError, match="not been published"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_requires_published_snapshot(monkeypatch):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=None,
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
query_results = iter([agent, None])
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results))
|
||||
|
||||
with pytest.raises(AgentAppGeneratorError, match="published version not found"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_allows_missing_legacy_app_model_config(monkeypatch):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=None,
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict={})
|
||||
query_results = iter([agent, snapshot])
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results))
|
||||
|
||||
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
assert features_dict["file_upload"] == {
|
||||
"allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"],
|
||||
"allowed_file_types": ["document", "image", "audio", "video"],
|
||||
"allowed_file_upload_methods": ["local_file", "remote_url"],
|
||||
"enabled": True,
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 3,
|
||||
}
|
||||
assert user_input_form == []
|
||||
@@ -5,11 +5,11 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
||||
from dify_agent.protocol import SandboxListResponse, SandboxReadResponse, SandboxUploadResponse
|
||||
from dify_agent.protocol import SandboxListResponse, SandboxReadResponse
|
||||
|
||||
from controllers.console import agent_app_sandbox as module
|
||||
from models.model import App, AppMode, IconType
|
||||
from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError
|
||||
from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError, AgentSandboxUploadDownload
|
||||
|
||||
|
||||
class _AgentAppService:
|
||||
@@ -28,11 +28,11 @@ class _AgentAppService:
|
||||
self.calls.append(("read", tenant_id, app_id, conversation_id, path))
|
||||
return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
|
||||
|
||||
def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxUploadResponse:
|
||||
def upload_file(
|
||||
self, *, tenant_id: str, app_id: str, conversation_id: str, path: str
|
||||
) -> AgentSandboxUploadDownload:
|
||||
self.calls.append(("upload", tenant_id, app_id, conversation_id, path))
|
||||
return SandboxUploadResponse(
|
||||
path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
|
||||
)
|
||||
return AgentSandboxUploadDownload(url="https://files.example/report.txt")
|
||||
|
||||
|
||||
class _WorkflowService:
|
||||
@@ -74,11 +74,9 @@ class _WorkflowService:
|
||||
node_id: str,
|
||||
node_execution_id: str | None,
|
||||
path: str,
|
||||
) -> SandboxUploadResponse:
|
||||
) -> AgentSandboxUploadDownload:
|
||||
self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path))
|
||||
return SandboxUploadResponse(
|
||||
path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
|
||||
)
|
||||
return AgentSandboxUploadDownload(url="https://files.example/upload.txt")
|
||||
|
||||
|
||||
def _app_model(app_id: str = "app-1") -> App:
|
||||
@@ -143,7 +141,7 @@ def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPat
|
||||
assert info == {"session_id": "abc1234", "workspace_cwd": "~/workspace/abc1234"}
|
||||
assert listing["path"] == "sub/report.txt"
|
||||
assert preview["text"] == "hello"
|
||||
assert upload["file"]["reference"] == "dify-file-ref:file-1"
|
||||
assert upload == {"url": "https://files.example/report.txt"}
|
||||
assert service.calls == [
|
||||
("info", "tenant-1", "app-1", "conv-1", ""),
|
||||
("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"),
|
||||
@@ -203,7 +201,7 @@ def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.Monk
|
||||
|
||||
assert listing["path"] == "out.txt"
|
||||
assert preview["text"] == "hello"
|
||||
assert upload["file"]["reference"] == "dify-file-ref:file-1"
|
||||
assert upload == {"url": "https://files.example/upload.txt"}
|
||||
assert service.calls == [
|
||||
("list", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"),
|
||||
("read", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"),
|
||||
|
||||
@@ -35,6 +35,7 @@ from controllers.openapi._errors import (
|
||||
RecipientSurfaceMismatch,
|
||||
)
|
||||
from controllers.service_api.app.error import (
|
||||
AgentNotPublishedError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ConversationCompletedError,
|
||||
@@ -306,6 +307,7 @@ ERROR_MATRIX = [
|
||||
(InternalServerError(), 500, "internal_server_error"),
|
||||
(BadGateway("x"), 502, "bad_gateway"),
|
||||
(AppUnavailableError(), 400, "app_unavailable"),
|
||||
(AgentNotPublishedError(), 400, "agent_not_published"),
|
||||
(ConversationCompletedError(), 400, "conversation_completed"),
|
||||
(ProviderNotInitializeError(), 400, "provider_not_initialize"),
|
||||
(ProviderQuotaExceededError(), 400, "provider_quota_exceeded"),
|
||||
|
||||
@@ -9,7 +9,8 @@ import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.service_api.app.app import AppInfoApi, AppMetaApi, AppParameterApi
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError
|
||||
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
|
||||
from models.account import TenantStatus
|
||||
from models.model import App, AppMode
|
||||
from tests.unit_tests.conftest import setup_mock_tenant_owner_execute_result
|
||||
@@ -185,6 +186,41 @@ class TestAppParameterApi:
|
||||
]
|
||||
mock_get_agent_parameters.assert_called_once_with(mock_app_model)
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch(
|
||||
"controllers.service_api.app.app.get_published_agent_app_feature_dict_and_user_input_form",
|
||||
side_effect=AgentAppNotPublishedError("Agent has not been published"),
|
||||
)
|
||||
def test_get_parameters_for_unpublished_agent_app_raises_friendly_error(
|
||||
self,
|
||||
mock_get_agent_parameters,
|
||||
mock_db,
|
||||
mock_validate_token,
|
||||
mock_current_app,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
mock_app_model,
|
||||
):
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
mock_app_model.mode = AppMode.AGENT
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = mock_app_model.id
|
||||
mock_api_token.tenant_id = mock_app_model.tenant_id
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.status = TenantStatus.NORMAL
|
||||
mock_db.session.get.side_effect = [mock_app_model, mock_tenant]
|
||||
setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, Mock(current_tenant=mock_tenant))
|
||||
|
||||
with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
with pytest.raises(AgentNotPublishedError):
|
||||
AppParameterApi().get()
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
|
||||
@@ -31,10 +31,12 @@ from controllers.service_api.app.completion import (
|
||||
CompletionStopApi,
|
||||
)
|
||||
from controllers.service_api.app.error import (
|
||||
AgentNotPublishedError,
|
||||
AppUnavailableError,
|
||||
ConversationCompletedError,
|
||||
NotChatAppError,
|
||||
)
|
||||
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
|
||||
from core.errors.error import QuotaExceededError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from models.model import App, AppMode, EndUser
|
||||
@@ -516,6 +518,22 @@ class TestChatApiController:
|
||||
with pytest.raises(BadRequest):
|
||||
handler(api, session=Mock(), app_model=app_model, end_user=end_user)
|
||||
|
||||
def test_agent_not_published_error_mapped(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
AppGenerateService,
|
||||
"generate",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AgentAppNotPublishedError("Agent has not been published")),
|
||||
)
|
||||
|
||||
api = ChatApi()
|
||||
handler = unwrap(api.post)
|
||||
app_model = SimpleNamespace(mode=AppMode.AGENT.value)
|
||||
end_user = SimpleNamespace()
|
||||
|
||||
with app.test_request_context("/chat-messages", method="POST", json={"inputs": {}, "query": "hi"}):
|
||||
with pytest.raises(AgentNotPublishedError):
|
||||
handler(api, session=Mock(), app_model=app_model, end_user=end_user)
|
||||
|
||||
|
||||
class TestChatStopApiController:
|
||||
def test_wrong_mode(self, app: Flask) -> None:
|
||||
|
||||
@@ -9,7 +9,8 @@ import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
|
||||
from controllers.web.error import AppUnavailableError
|
||||
from controllers.web.error import AgentNotPublishedError, AppUnavailableError
|
||||
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -80,6 +81,18 @@ class TestAppParameterApi:
|
||||
with pytest.raises(AppUnavailableError):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
def test_agent_mode_unpublished_raises_friendly_error(self, app: Flask) -> None:
|
||||
app_model = SimpleNamespace(mode="agent")
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
patch(
|
||||
"controllers.web.app.get_published_agent_app_feature_dict_and_user_input_form",
|
||||
side_effect=AgentAppNotPublishedError("Agent has not been published"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(AgentNotPublishedError):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppMeta
|
||||
|
||||
@@ -10,6 +10,7 @@ from flask import Flask
|
||||
|
||||
from controllers.web.completion import ChatApi, ChatStopApi, CompletionApi, CompletionStopApi
|
||||
from controllers.web.error import (
|
||||
AgentNotPublishedError,
|
||||
CompletionRequestError,
|
||||
NotChatAppError,
|
||||
NotCompletionAppError,
|
||||
@@ -17,6 +18,7 @@ from controllers.web.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
|
||||
@@ -142,6 +144,19 @@ class TestChatApi:
|
||||
with pytest.raises(CompletionRequestError):
|
||||
ChatApi().post(_chat_app(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.completion.AppGenerateService.generate",
|
||||
side_effect=AgentAppNotPublishedError("Agent has not been published"),
|
||||
)
|
||||
@patch("controllers.web.completion.web_ns")
|
||||
def test_agent_not_published_error_mapped(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {}, "query": "x"}
|
||||
app_model = SimpleNamespace(id="app-1", mode="agent")
|
||||
|
||||
with app.test_request_context("/chat-messages", method="POST"):
|
||||
with pytest.raises(AgentNotPublishedError):
|
||||
ChatApi().post(app_model, _end_user())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChatStopApi
|
||||
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
|
||||
from controllers.common.errors import InvalidArgumentError, NotFoundError
|
||||
from controllers.web.error import (
|
||||
AgentNotPublishedError,
|
||||
AppMoreLikeThisDisabledError,
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
AppUnavailableError,
|
||||
@@ -29,6 +30,7 @@ from controllers.web.error import (
|
||||
|
||||
_ERROR_SPECS: list[tuple[type, str, int]] = [
|
||||
(AppUnavailableError, "app_unavailable", 400),
|
||||
(AgentNotPublishedError, "agent_not_published", 400),
|
||||
(NotCompletionAppError, "not_completion_app", 400),
|
||||
(NotChatAppError, "not_chat_app", 400),
|
||||
(NotWorkflowAppError, "not_workflow_app", 400),
|
||||
|
||||
@@ -65,6 +65,36 @@ 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"] == ""
|
||||
assert d["file_upload"] == {
|
||||
"allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"],
|
||||
"allowed_file_types": ["document", "image", "audio", "video"],
|
||||
"allowed_file_upload_methods": ["local_file", "remote_url"],
|
||||
"enabled": True,
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_soul_file_upload_overrides_legacy_app_model_config():
|
||||
fake_amc = SimpleNamespace(
|
||||
to_dict=lambda: {
|
||||
"file_upload": {
|
||||
"enabled": False,
|
||||
"image": {"enabled": False},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
d = AgentAppConfigManager._synthesize_config_dict(AgentSoulConfig(), fake_amc) # type: ignore[arg-type]
|
||||
|
||||
assert d["file_upload"] == {
|
||||
"allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"],
|
||||
"allowed_file_types": ["document", "image", "audio", "video"],
|
||||
"allowed_file_upload_methods": ["local_file", "remote_url"],
|
||||
"enabled": True,
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_prompt_type_defaults_to_simple():
|
||||
|
||||
@@ -568,7 +568,7 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch):
|
||||
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert rows[0].thought == "I need to inspect the file."
|
||||
assert rows[0].tool is None
|
||||
assert rows[0].tool == ""
|
||||
assert rows[1].tool == "bash"
|
||||
assert rows[1].tool_input == '{"cmd": "ls"}'
|
||||
assert rows[1].observation == "ok"
|
||||
@@ -656,9 +656,9 @@ def test_tool_result_without_identity_does_not_attach_to_previous_tool(monkeypat
|
||||
assert len(rows) == 2
|
||||
assert rows[0].tool == "shell_run"
|
||||
assert rows[0].tool_input == '{"script": "npx skills find browser"}'
|
||||
assert rows[0].observation is None
|
||||
assert rows[1].tool is None
|
||||
assert rows[1].tool_input is None
|
||||
assert rows[0].observation == ""
|
||||
assert rows[1].tool == ""
|
||||
assert rows[1].tool_input == ""
|
||||
assert rows[1].observation == "Knowledge base search results: browser skill"
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ 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
|
||||
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
_SOUL_DICT = {
|
||||
@@ -78,7 +78,7 @@ class TestResolveAgentById:
|
||||
|
||||
class TestResolveAgent:
|
||||
def test_success_chains_to_resolve_by_id(self, monkeypatch: pytest.MonkeyPatch):
|
||||
bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1")
|
||||
bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", active_config_is_published=True)
|
||||
inner_agent = SimpleNamespace(id="agent-1")
|
||||
snapshot = _snapshot()
|
||||
# scalar order: bound agent (in _resolve_agent), then agent + snapshot (in _resolve_agent_by_id)
|
||||
@@ -97,6 +97,23 @@ class TestResolveAgent:
|
||||
assert config_version_kind == "snapshot"
|
||||
assert soul.model is not None
|
||||
|
||||
def test_unpublished_agent_raises_before_model_resolution(self, monkeypatch: pytest.MonkeyPatch):
|
||||
bound_agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="snap-1",
|
||||
active_config_is_published=False,
|
||||
)
|
||||
_patch_session(monkeypatch, [bound_agent])
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
|
||||
|
||||
with pytest.raises(AgentAppNotPublishedError, match="not been published"):
|
||||
AgentAppGenerator()._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_session(monkeypatch, [None])
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
|
||||
|
||||
+52
-2
@@ -721,6 +721,56 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
assert response is not None
|
||||
assert response.id == "thought"
|
||||
|
||||
def test_agent_thought_to_stream_response_normalizes_null_display_fields(self, monkeypatch: pytest.MonkeyPatch):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
message = _make_message()
|
||||
|
||||
pipeline = EasyUIBasedGenerateTaskPipeline(
|
||||
application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT),
|
||||
queue_manager=_FakeQueueManager(),
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
agent_thought = _agent_thought()
|
||||
agent_thought.thought = None
|
||||
agent_thought.observation = None
|
||||
agent_thought.tool = None
|
||||
agent_thought.tool_input = None
|
||||
agent_thought.message_files = None
|
||||
|
||||
class _Session:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def scalar(self, *args, **kwargs):
|
||||
return agent_thought
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session",
|
||||
_Session,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db",
|
||||
_FakeDb(),
|
||||
)
|
||||
|
||||
response = pipeline._agent_thought_to_stream_response(QueueAgentThoughtEvent(agent_thought_id="thought"))
|
||||
|
||||
assert response is not None
|
||||
assert response.thought == ""
|
||||
assert response.observation == ""
|
||||
assert response.tool == ""
|
||||
assert response.tool_input == ""
|
||||
assert response.model_dump(mode="json")["message_files"] == []
|
||||
|
||||
def test_process_routes_to_stream_and_starts_conversation_name_generation(self):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
message = _make_message()
|
||||
@@ -1280,7 +1330,7 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
usage_metadata = cast(dict[str, object], response.metadata["usage"])
|
||||
assert usage_metadata["prompt_tokens"] == 1
|
||||
|
||||
def test_record_files_returns_none_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_record_files_returns_empty_list_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
message = _make_message()
|
||||
pipeline = EasyUIBasedGenerateTaskPipeline(
|
||||
@@ -1316,7 +1366,7 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
|
||||
response = pipeline._message_end_to_stream_response()
|
||||
|
||||
assert response.files is None
|
||||
assert response.files == []
|
||||
|
||||
def test_record_files_handles_local_fallback_and_tool_url_variants(self, monkeypatch: pytest.MonkeyPatch):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
|
||||
@@ -6,7 +6,7 @@ SSE event, which is critical for vision/image chat responses to render correctly
|
||||
|
||||
Test Coverage:
|
||||
- Files array populated when MessageFile records exist
|
||||
- Files array is None when no MessageFile records exist
|
||||
- Files array is empty when no MessageFile records exist
|
||||
- Correct signed URL generation for LOCAL_FILE transfer method
|
||||
- Correct URL handling for REMOTE_URL transfer method
|
||||
- Correct URL handling for TOOL_FILE transfer method
|
||||
@@ -90,7 +90,7 @@ class TestMessageEndStreamResponseFiles:
|
||||
return upload_file
|
||||
|
||||
def test_message_end_with_no_files(self, mock_pipeline):
|
||||
"""Test that files array is None when no MessageFile records exist."""
|
||||
"""Test that files array is empty when no MessageFile records exist."""
|
||||
# Arrange
|
||||
with (
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
|
||||
@@ -108,9 +108,10 @@ class TestMessageEndStreamResponseFiles:
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageEndStreamResponse)
|
||||
assert result.files is None
|
||||
assert result.files == []
|
||||
assert result.id == mock_pipeline._message_id
|
||||
assert result.metadata == {"test": "metadata"}
|
||||
mock_pipeline._task_state.metadata.model_dump.assert_called_once_with(exclude_none=True)
|
||||
|
||||
def test_message_end_with_local_file(self, mock_pipeline, mock_message_file_local, mock_upload_file):
|
||||
"""Test that files array is populated correctly for LOCAL_FILE transfer method."""
|
||||
|
||||
@@ -227,6 +227,15 @@ def _previous_node_prompt_payload(result, selector: str) -> object:
|
||||
raise AssertionError(f"missing prompt payload for {selector}")
|
||||
|
||||
|
||||
def _uploaded_workflow_files_prompt_payload(result) -> object:
|
||||
prefix = " - sys.files: "
|
||||
user_prompt = _workflow_user_prompt(result)
|
||||
for line in user_prompt.splitlines():
|
||||
if line.startswith(prefix):
|
||||
return json.loads(line.removeprefix(prefix))
|
||||
raise AssertionError("missing prompt payload for sys.files")
|
||||
|
||||
|
||||
def test_builds_create_run_request_from_agent_soul_and_node_job():
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context())
|
||||
|
||||
@@ -1252,6 +1261,48 @@ def test_previous_node_file_array_uses_agent_stub_download_mappings_in_workflow_
|
||||
]
|
||||
|
||||
|
||||
def test_uploaded_workflow_files_are_included_without_prompt_marker():
|
||||
file_reference = build_file_reference(record_id="uploaded-file-1")
|
||||
|
||||
class UploadedFilesVariablePool(FakeVariablePool):
|
||||
def get(self, selector):
|
||||
if list(selector) == ["sys", "files"]:
|
||||
return ArrayFileSegment(
|
||||
value=[
|
||||
File(
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
reference=file_reference,
|
||||
remote_url=None,
|
||||
filename="requirements.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=12,
|
||||
)
|
||||
]
|
||||
)
|
||||
return super().get(selector)
|
||||
|
||||
context = replace(_context(), variable_pool=UploadedFilesVariablePool())
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "Answer the user's question.",
|
||||
}
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
user_prompt = _workflow_user_prompt(result)
|
||||
assert "- Uploaded workflow files:" in user_prompt
|
||||
assert _uploaded_workflow_files_prompt_payload(result) == [
|
||||
{
|
||||
"transfer_method": "local_file",
|
||||
"reference": file_reference,
|
||||
}
|
||||
]
|
||||
assert "Previous node outputs:" not in user_prompt
|
||||
|
||||
|
||||
def test_previous_node_remote_url_file_mapping_is_not_truncated_in_workflow_context():
|
||||
remote_url = "https://example.com/" + ("a" * 2100) + ".pdf"
|
||||
|
||||
|
||||
@@ -14,6 +14,23 @@ from services.entities.agent_entities import (
|
||||
)
|
||||
|
||||
|
||||
def test_default_agent_soul_enables_file_upload_feature():
|
||||
agent_soul = AgentSoulConfig()
|
||||
|
||||
file_upload = agent_soul.model_dump(mode="json")["app_features"]["file_upload"]
|
||||
assert file_upload == {
|
||||
"allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"],
|
||||
"allowed_file_types": ["document", "image", "audio", "video"],
|
||||
"allowed_file_upload_methods": ["local_file", "remote_url"],
|
||||
"enabled": True,
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 3,
|
||||
}
|
||||
# The product default should be visible in API responses, but it must not
|
||||
# make workflow-only payload validation treat app_features as user-authored.
|
||||
assert bool(agent_soul.app_features) is False
|
||||
|
||||
|
||||
def test_workflow_variant_rejects_agent_app_only_fields():
|
||||
with pytest.raises(ValueError):
|
||||
ComposerSavePayload.model_validate(
|
||||
@@ -257,6 +274,16 @@ def test_knowledge_query_mode_uses_stable_backend_enums():
|
||||
},
|
||||
"knowledge set dataset ids must be unique",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str):
|
||||
with pytest.raises(ValidationError, match=match):
|
||||
AgentSoulConfig.model_validate({"knowledge": knowledge_payload})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("knowledge_payload", "match"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
@@ -317,9 +344,25 @@ def test_knowledge_query_mode_uses_stable_backend_enums():
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str):
|
||||
with pytest.raises(ValidationError, match=match):
|
||||
AgentSoulConfig.model_validate({"knowledge": knowledge_payload})
|
||||
def test_knowledge_runtime_requirements_block_publish_but_not_draft_save(knowledge_payload, match: str):
|
||||
draft_payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.AGENT_APP,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
|
||||
"agent_soul": {"knowledge": knowledge_payload},
|
||||
}
|
||||
)
|
||||
ComposerConfigValidator.validate_draft_save_payload(draft_payload)
|
||||
|
||||
publish_payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.AGENT_APP,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
|
||||
"agent_soul": {"knowledge": knowledge_payload},
|
||||
}
|
||||
)
|
||||
with pytest.raises(InvalidComposerConfigError, match=match):
|
||||
ComposerConfigValidator.validate_publish_payload(publish_payload)
|
||||
|
||||
|
||||
def test_agent_soul_model_config_is_first_class_without_credentials():
|
||||
|
||||
@@ -36,6 +36,7 @@ from services.agent.agent_soul_state import agent_soul_has_model
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.errors import (
|
||||
AgentModelNotConfiguredError,
|
||||
AgentNameConflictError,
|
||||
AgentNotFoundError,
|
||||
AgentVersionConflictError,
|
||||
@@ -576,6 +577,55 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_publish_agent_app_draft_rejects_missing_model(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Iris",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=False,
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
)
|
||||
fake_session = FakeSession(scalar=[agent, draft])
|
||||
|
||||
def fail_create_config_version(**_kwargs):
|
||||
raise AssertionError("config version must not be created when Agent Soul has no model")
|
||||
|
||||
def fail_validate_knowledge_datasets(**_kwargs):
|
||||
raise AssertionError("knowledge datasets must not be validated when Agent Soul has no model")
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
||||
monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", fail_validate_knowledge_datasets)
|
||||
monkeypatch.setattr(AgentComposerService, "_create_config_version", fail_create_config_version)
|
||||
|
||||
with pytest.raises(AgentModelNotConfiguredError) as exc_info:
|
||||
AgentComposerService.publish_agent_app_draft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
version_note="ship it",
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == "agent_model_not_configured"
|
||||
assert agent.active_config_snapshot_id == "version-1"
|
||||
assert agent.active_config_is_published is False
|
||||
assert draft.base_snapshot_id == "version-1"
|
||||
assert fake_session.commits == 0
|
||||
|
||||
|
||||
def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
@@ -4257,31 +4307,7 @@ def test_dataset_rows_filters_malformed_ids(monkeypatch: pytest.MonkeyPatch):
|
||||
assert captured == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variant", "save_call"),
|
||||
[
|
||||
(
|
||||
ComposerVariant.AGENT_APP,
|
||||
lambda payload: AgentComposerService.save_agent_app_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
(
|
||||
ComposerVariant.WORKFLOW,
|
||||
lambda payload: AgentComposerService.save_workflow_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
node_id="node-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pytest.MonkeyPatch, variant, save_call):
|
||||
def test_validate_knowledge_datasets_rejects_malformed_ids_without_dataset_lookup(monkeypatch: pytest.MonkeyPatch):
|
||||
captured = {"calls": 0}
|
||||
|
||||
def fake_get_datasets_by_ids(ids, tenant_id):
|
||||
@@ -4294,60 +4320,29 @@ def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pyte
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"variant": variant.value,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
"soul_lock": {"locked": False},
|
||||
"agent_soul": {
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "not-a-uuid"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
}
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "not-a-uuid"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="not-a-uuid"):
|
||||
save_call(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id="tenant-1", agent_soul=agent_soul)
|
||||
|
||||
assert captured == {"calls": 0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variant", "save_call"),
|
||||
[
|
||||
(
|
||||
ComposerVariant.AGENT_APP,
|
||||
lambda payload: AgentComposerService.save_agent_app_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
(
|
||||
ComposerVariant.WORKFLOW,
|
||||
lambda payload: AgentComposerService.save_workflow_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
node_id="node-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
|
||||
monkeypatch: pytest.MonkeyPatch, variant, save_call
|
||||
):
|
||||
def test_validate_knowledge_datasets_rejects_missing_or_out_of_scope_datasets(monkeypatch: pytest.MonkeyPatch):
|
||||
captured = {}
|
||||
missing_dataset_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
@@ -4360,20 +4355,70 @@ def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": missing_dataset_id}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id):
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id="tenant-1", agent_soul=agent_soul)
|
||||
|
||||
assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"}
|
||||
|
||||
|
||||
def test_save_agent_composer_allows_incomplete_knowledge_draft(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=True,
|
||||
updated_by=None,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=AgentSoulConfig().model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent])
|
||||
saved = {}
|
||||
|
||||
import services.dataset_service as dataset_service_module
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(
|
||||
dataset_service_module.DatasetService,
|
||||
"get_datasets_by_ids",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("draft save must skip dataset lookup")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_save_agent_draft",
|
||||
lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True})
|
||||
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": variant.value,
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
"soul_lock": {"locked": False},
|
||||
"agent_soul": {
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": missing_dataset_id}],
|
||||
"datasets": [{"id": "not-a-uuid"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"retrieval": {"mode": "single"},
|
||||
"metadata_filtering": {"mode": "automatic"},
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4381,10 +4426,20 @@ def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id):
|
||||
save_call(payload)
|
||||
result = AgentComposerService.save_agent_composer(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"}
|
||||
assert result["loaded"] is True
|
||||
assert saved["draft_type"] == AgentConfigDraftType.DRAFT
|
||||
assert saved["agent_soul"].knowledge.sets[0].retrieval.mode == "single"
|
||||
assert saved["agent_soul"].knowledge.sets[0].retrieval.model is None
|
||||
assert saved["agent_soul"].knowledge.sets[0].metadata_filtering.mode == "automatic"
|
||||
assert saved["agent_soul"].knowledge.sets[0].metadata_filtering.metadata_model_config is None
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@@ -18,8 +18,10 @@ from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, Agen
|
||||
from services.agent_app_sandbox_service import (
|
||||
AgentAppSandboxService,
|
||||
AgentSandboxInspectorError,
|
||||
AgentSandboxUploadDownload,
|
||||
WorkflowAgentSandboxService,
|
||||
_default_client_factory,
|
||||
_upload_download_response,
|
||||
)
|
||||
|
||||
|
||||
@@ -129,6 +131,30 @@ def test_agent_app_sandbox_service_builds_locator_and_proxies() -> None:
|
||||
assert store.scope == ("tenant-1", "app-1", "conv-1")
|
||||
|
||||
|
||||
def test_agent_app_sandbox_service_upload_returns_download_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
store = FakeStore(_stored_session())
|
||||
client = FakeClient()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_upload_download_response(*, tenant_id: str, file_mapping: dict[str, object]) -> AgentSandboxUploadDownload:
|
||||
captured["tenant_id"] = tenant_id
|
||||
captured["file_mapping"] = file_mapping
|
||||
return AgentSandboxUploadDownload(url="https://files.example/report.txt?token=1&as_attachment=true")
|
||||
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service._upload_download_response", fake_upload_download_response)
|
||||
service = AgentAppSandboxService(session_store=store, client_factory=lambda: client) # type: ignore[arg-type]
|
||||
|
||||
result = service.upload_file(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path="report.txt")
|
||||
|
||||
assert result.url == "https://files.example/report.txt?token=1&as_attachment=true"
|
||||
assert client.calls == [("upload", "report.txt")]
|
||||
assert store.scope == ("tenant-1", "app-1", "conv-1")
|
||||
assert captured == {
|
||||
"tenant_id": "tenant-1",
|
||||
"file_mapping": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
}
|
||||
|
||||
|
||||
def test_agent_app_sandbox_service_raises_when_no_active_session() -> None:
|
||||
service = AgentAppSandboxService(session_store=FakeStore(None), client_factory=lambda: FakeClient()) # type: ignore[arg-type]
|
||||
|
||||
@@ -210,9 +236,19 @@ def _insert_workflow_session(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_runtime_session_table")
|
||||
def test_workflow_sandbox_service_resolves_locator_and_proxies() -> None:
|
||||
def test_workflow_sandbox_service_resolves_locator_and_returns_download_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_insert_workflow_session()
|
||||
client = FakeClient()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_upload_download_response(*, tenant_id: str, file_mapping: dict[str, object]) -> AgentSandboxUploadDownload:
|
||||
captured["tenant_id"] = tenant_id
|
||||
captured["file_mapping"] = file_mapping
|
||||
return AgentSandboxUploadDownload(url="https://files.example/report.txt?token=1&as_attachment=true")
|
||||
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service._upload_download_response", fake_upload_download_response)
|
||||
service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type]
|
||||
|
||||
result = service.upload_file(
|
||||
@@ -224,8 +260,96 @@ def test_workflow_sandbox_service_resolves_locator_and_proxies() -> None:
|
||||
path="report.txt",
|
||||
)
|
||||
|
||||
assert result.file.reference == "dify-file-ref:file-1"
|
||||
assert result.url == "https://files.example/report.txt?token=1&as_attachment=true"
|
||||
assert client.calls == [("upload", "report.txt")]
|
||||
assert captured == {
|
||||
"tenant_id": "tenant-1",
|
||||
"file_mapping": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
}
|
||||
|
||||
|
||||
def test_upload_download_response_resolves_signed_external_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
built_file = object()
|
||||
built_with: dict[str, object] = {}
|
||||
|
||||
def fake_build_from_mapping(*, mapping: dict[str, object], tenant_id: str, access_controller: object) -> object:
|
||||
built_with["mapping"] = mapping
|
||||
built_with["tenant_id"] = tenant_id
|
||||
built_with["access_controller"] = access_controller
|
||||
return built_file
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, *, file_access_controller: object) -> None:
|
||||
self.file_access_controller = file_access_controller
|
||||
|
||||
def resolve_file_url(self, *, file: object, for_external: bool) -> str:
|
||||
assert file is built_file
|
||||
assert for_external is True
|
||||
return "https://files.example/files/tools/tool-file.txt?timestamp=1&nonce=2&sign=3"
|
||||
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.file_factory.build_from_mapping", fake_build_from_mapping)
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.DifyWorkflowFileRuntime", FakeRuntime)
|
||||
|
||||
result = _upload_download_response(
|
||||
tenant_id="tenant-1",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
)
|
||||
|
||||
assert result.url == (
|
||||
"https://files.example/files/tools/tool-file.txt?timestamp=1&nonce=2&sign=3&as_attachment=true"
|
||||
)
|
||||
assert built_with["mapping"] == {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
|
||||
assert built_with["tenant_id"] == "tenant-1"
|
||||
assert built_with["access_controller"] is not None
|
||||
|
||||
|
||||
def test_upload_download_response_maps_resolution_failure_to_inspector_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_build_from_mapping(*, mapping: dict[str, object], tenant_id: str, access_controller: object) -> object:
|
||||
del mapping, tenant_id, access_controller
|
||||
raise ValueError("missing tool file")
|
||||
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.file_factory.build_from_mapping", fake_build_from_mapping)
|
||||
|
||||
with pytest.raises(AgentSandboxInspectorError) as exc_info:
|
||||
_upload_download_response(
|
||||
tenant_id="tenant-1",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "sandbox_upload_download_unavailable"
|
||||
assert exc_info.value.status_code == 502
|
||||
|
||||
|
||||
def test_upload_download_response_maps_missing_url_to_inspector_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
built_file = object()
|
||||
|
||||
def fake_build_from_mapping(*, mapping: dict[str, object], tenant_id: str, access_controller: object) -> object:
|
||||
del mapping, tenant_id, access_controller
|
||||
return built_file
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, *, file_access_controller: object) -> None:
|
||||
self.file_access_controller = file_access_controller
|
||||
|
||||
def resolve_file_url(self, *, file: object, for_external: bool) -> None:
|
||||
assert file is built_file
|
||||
assert for_external is True
|
||||
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.file_factory.build_from_mapping", fake_build_from_mapping)
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.DifyWorkflowFileRuntime", FakeRuntime)
|
||||
|
||||
with pytest.raises(AgentSandboxInspectorError) as exc_info:
|
||||
_upload_download_response(
|
||||
tenant_id="tenant-1",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "sandbox_upload_download_unavailable"
|
||||
assert exc_info.value.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_runtime_session_table")
|
||||
|
||||
Reference in New Issue
Block a user