mirror of
https://github.com/langgenius/dify.git
synced 2026-07-18 07:44:36 -04: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:
@@ -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")
|
||||
|
||||
@@ -5,6 +5,20 @@ runtime adapters or their optional dependencies. Server-only adapter entry point
|
||||
remain under ``dify_agent.adapters.llm``.
|
||||
"""
|
||||
|
||||
from dify_agent.client import Client
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dify_agent.client import Client
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
if name == "Client":
|
||||
from dify_agent.client import Client
|
||||
|
||||
return Client
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = ["Client"]
|
||||
|
||||
@@ -37,10 +37,7 @@ from pydantic_ai.exceptions import UnexpectedModelBehavior
|
||||
from pydantic_ai.messages import (
|
||||
AudioUrl,
|
||||
BinaryContent,
|
||||
BuiltinToolCallPart,
|
||||
BuiltinToolReturnPart,
|
||||
CachePoint,
|
||||
CompactionPart,
|
||||
DocumentUrl,
|
||||
FilePart,
|
||||
FinishReason,
|
||||
@@ -357,10 +354,8 @@ def _map_model_response_to_prompt_message(
|
||||
),
|
||||
)
|
||||
)
|
||||
elif isinstance(part, BuiltinToolCallPart | BuiltinToolReturnPart | CompactionPart):
|
||||
raise UnexpectedModelBehavior(f"Unsupported response part for daemon adapter: {type(part).__name__}")
|
||||
else:
|
||||
assert_never(part)
|
||||
raise UnexpectedModelBehavior(f"Unsupported response part for daemon adapter: {type(part).__name__}")
|
||||
|
||||
content = _normalize_prompt_content(content_parts)
|
||||
if content is None and not tool_calls:
|
||||
@@ -487,10 +482,16 @@ def _map_binary_content_to_prompt_content(
|
||||
def _normalize_prompt_content(
|
||||
content: list[PromptMessageContentUnionTypes],
|
||||
) -> str | list[PromptMessageContentUnionTypes] | None:
|
||||
"""Collapse text-only daemon message content to the string form.
|
||||
|
||||
The daemon protocol supports content-part lists for multimodal messages, but
|
||||
text-only history is safer as plain text because provider plugins commonly
|
||||
JSON-encode text payloads without Graphon model encoders.
|
||||
"""
|
||||
if not content:
|
||||
return None
|
||||
if len(content) == 1 and isinstance(content[0], TextPromptMessageContent):
|
||||
return content[0].data
|
||||
if all(isinstance(item, TextPromptMessageContent) for item in content):
|
||||
return "".join(item.data for item in content)
|
||||
return content
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Provider-agnostic shell adapter exports for the Dify agent."""
|
||||
"""Provider-agnostic shell adapter exports for the Dify agent.
|
||||
|
||||
Keep this package root light so importing shell protocols does not eagerly
|
||||
require ``pydantic_settings`` or shellctl runtime dependencies.
|
||||
"""
|
||||
|
||||
from dify_agent.adapters.shell.config import DEFAULT_SHELL_PROVIDER, ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.factory import create_shell_provider
|
||||
from dify_agent.adapters.shell.protocols import (
|
||||
CompleteShellCommandResult,
|
||||
ShellCommandProtocol,
|
||||
@@ -14,6 +16,27 @@ from dify_agent.adapters.shell.protocols import (
|
||||
ShellResourceProtocol,
|
||||
)
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
if name == "DEFAULT_SHELL_PROVIDER":
|
||||
from dify_agent.adapters.shell.config import DEFAULT_SHELL_PROVIDER
|
||||
|
||||
return DEFAULT_SHELL_PROVIDER
|
||||
if name == "ShellAdapterSettings":
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
|
||||
return ShellAdapterSettings
|
||||
if name == "create_shell_provider":
|
||||
from dify_agent.adapters.shell.factory import create_shell_provider
|
||||
|
||||
return create_shell_provider
|
||||
if name == "shellctl":
|
||||
from importlib import import_module
|
||||
|
||||
return import_module("dify_agent.adapters.shell.shellctl")
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CompleteShellCommandResult",
|
||||
"DEFAULT_SHELL_PROVIDER",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Zero-side-effect Agent Stub constants shared across client-safe modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_DRIVE_BASE"
|
||||
DEFAULT_AGENT_STUB_DRIVE_BASE: Final[str] = "/mnt/drive"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AGENT_STUB_DRIVE_BASE_ENV_VAR",
|
||||
"DEFAULT_AGENT_STUB_DRIVE_BASE",
|
||||
]
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dify_agent.agent_stub.cli._env import read_agent_stub_environment
|
||||
from dify_agent.agent_stub.client._agent_stub import connect_agent_stub_sync
|
||||
from dify_agent.agent_stub.client import connect_agent_stub_sync
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubConnectResponse
|
||||
|
||||
|
||||
|
||||
@@ -17,13 +17,14 @@ from dify_agent.agent_stub._drive_materialization import (
|
||||
from dify_agent.agent_stub.cli._drive import _build_skill_archive
|
||||
from dify_agent.agent_stub.cli._env import read_agent_stub_environment
|
||||
from dify_agent.agent_stub.cli._files import upload_tool_file_resource_from_environment
|
||||
from dify_agent.agent_stub.client._agent_stub import (
|
||||
from dify_agent.agent_stub.client import (
|
||||
AgentStubTransferError,
|
||||
AgentStubValidationError,
|
||||
request_agent_stub_config_file_pull_sync,
|
||||
request_agent_stub_config_manifest_sync,
|
||||
request_agent_stub_config_push_sync,
|
||||
request_agent_stub_config_file_pull_sync,
|
||||
request_agent_stub_config_skill_pull_sync,
|
||||
)
|
||||
from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubConfigFileRef,
|
||||
AgentStubConfigManifestResponse,
|
||||
|
||||
@@ -27,14 +27,16 @@ from dify_agent.agent_stub._drive_materialization import (
|
||||
materialize_drive_downloads,
|
||||
resolve_drive_destination,
|
||||
)
|
||||
from dify_agent.agent_stub._constants import DEFAULT_AGENT_STUB_DRIVE_BASE
|
||||
from dify_agent.agent_stub.cli._env import read_agent_stub_environment
|
||||
from dify_agent.agent_stub.cli._files import upload_tool_file_resource_from_environment
|
||||
from dify_agent.agent_stub.client._agent_stub import (
|
||||
from dify_agent.agent_stub.client import (
|
||||
AgentStubTransferError,
|
||||
AgentStubValidationError,
|
||||
download_file_bytes_from_signed_url_sync,
|
||||
request_agent_stub_drive_commit_sync,
|
||||
request_agent_stub_drive_manifest_sync,
|
||||
)
|
||||
from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubDriveCommitItem,
|
||||
AgentStubDriveCommitRequest,
|
||||
@@ -42,7 +44,6 @@ from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubDriveFileRef,
|
||||
AgentStubDriveItem,
|
||||
AgentStubDriveManifestResponse,
|
||||
DEFAULT_AGENT_STUB_DRIVE_BASE,
|
||||
)
|
||||
|
||||
_SKILL_MD_FILENAME = "SKILL.md"
|
||||
|
||||
@@ -6,11 +6,10 @@ from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR,
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR,
|
||||
DEFAULT_AGENT_STUB_DRIVE_BASE,
|
||||
normalize_agent_stub_api_base_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ from typing import ClassVar, Literal, cast
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from dify_agent.agent_stub.cli._env import read_agent_stub_environment
|
||||
from dify_agent.agent_stub.client._agent_stub import (
|
||||
from dify_agent.agent_stub.client import (
|
||||
AgentStubTransferError,
|
||||
AgentStubValidationError,
|
||||
download_file_bytes_from_signed_url_sync,
|
||||
request_agent_stub_file_download_sync,
|
||||
request_agent_stub_file_upload_sync,
|
||||
upload_file_to_signed_url_sync,
|
||||
)
|
||||
from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping, is_canonical_dify_file_reference
|
||||
|
||||
|
||||
|
||||
@@ -10,41 +10,15 @@ does not pull in FastAPI, Redis, shellctl, or JWE runtime dependencies.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cache
|
||||
from importlib import import_module
|
||||
import sys
|
||||
from typing import cast
|
||||
|
||||
import click
|
||||
import typer
|
||||
from typer.main import get_command
|
||||
|
||||
from dify_agent.agent_stub.cli._agent_stub import connect_from_environment
|
||||
from dify_agent.agent_stub.cli._config import (
|
||||
delete_config_files_from_environment,
|
||||
delete_config_skills_from_environment,
|
||||
manifest_from_environment,
|
||||
pull_config_files_from_environment,
|
||||
pull_config_note_from_environment,
|
||||
pull_config_skills_from_environment,
|
||||
push_config_env_from_environment,
|
||||
push_config_files_from_environment,
|
||||
push_config_note_from_environment,
|
||||
push_config_skills_from_environment,
|
||||
)
|
||||
from dify_agent.agent_stub.cli._drive import (
|
||||
DrivePushKind,
|
||||
format_drive_manifest,
|
||||
list_drive_manifest_from_environment,
|
||||
pull_drive_from_environment,
|
||||
push_drive_from_environment,
|
||||
)
|
||||
from dify_agent.agent_stub.cli._env import (
|
||||
MissingAgentStubEnvironmentError,
|
||||
has_agent_stub_environment,
|
||||
read_agent_stub_drive_base,
|
||||
)
|
||||
from dify_agent.agent_stub.cli._files import download_file_from_environment, upload_file_from_environment
|
||||
from dify_agent.agent_stub.client._errors import AgentStubClientError
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
|
||||
from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
|
||||
|
||||
_CONFIG_MANIFEST_STDOUT_EXCLUDE = {
|
||||
"skills": {"items": {"__all__": {"hash"}}},
|
||||
@@ -290,7 +264,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||
return
|
||||
json_output, forwarded_args = _extract_root_json_flag(args)
|
||||
if _is_unknown_bare_command(forwarded_args):
|
||||
if not has_agent_stub_environment():
|
||||
if not _env_module().has_agent_stub_environment():
|
||||
_show_root_help()
|
||||
_run_connect(argv=forwarded_args, json_output=json_output)
|
||||
return
|
||||
@@ -352,12 +326,15 @@ def render_agent_stub_cli_help(args: tuple[str, ...]) -> str:
|
||||
|
||||
|
||||
def _run_connect(*, argv: list[str], json_output: bool) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
agent_stub_module = _agent_stub_module()
|
||||
try:
|
||||
response = connect_from_environment(argv=argv)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = agent_stub_module.connect_from_environment(argv=argv)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
@@ -368,12 +345,15 @@ def _run_connect(*, argv: list[str], json_output: bool) -> None:
|
||||
|
||||
|
||||
def _run_file_upload(*, path: str) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
files_module = _files_module()
|
||||
try:
|
||||
response = upload_file_from_environment(path=path)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = files_module.upload_file_from_environment(path=path)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(response.model_dump_json())
|
||||
@@ -386,41 +366,50 @@ def _run_file_download(
|
||||
mapping: str | None,
|
||||
local_dir: str | None,
|
||||
) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
files_module = _files_module()
|
||||
try:
|
||||
response = download_file_from_environment(
|
||||
response = files_module.download_file_from_environment(
|
||||
transfer_method=transfer_method,
|
||||
reference_or_url=reference_or_url,
|
||||
mapping=mapping,
|
||||
local_dir=local_dir,
|
||||
)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(str(response.path))
|
||||
|
||||
|
||||
def _run_config_manifest() -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
config_module = _config_module()
|
||||
try:
|
||||
response = manifest_from_environment()
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = config_module.manifest_from_environment()
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(response.model_dump_json(exclude=_CONFIG_MANIFEST_STDOUT_EXCLUDE))
|
||||
|
||||
|
||||
def _run_config_skill_pull(*, names: list[str] | None, local_dir: str | None, json_output: bool) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
config_module = _config_module()
|
||||
try:
|
||||
response = pull_config_skills_from_environment(names=names, local_dir=local_dir)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = config_module.pull_config_skills_from_environment(names=names, local_dir=local_dir)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
if json_output:
|
||||
@@ -434,12 +423,15 @@ def _run_config_skill_pull(*, names: list[str] | None, local_dir: str | None, js
|
||||
|
||||
|
||||
def _run_config_file_pull(*, names: list[str] | None, local_dir: str | None, json_output: bool) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
config_module = _config_module()
|
||||
try:
|
||||
response = pull_config_files_from_environment(names=names, local_dir=local_dir)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = config_module.pull_config_files_from_environment(names=names, local_dir=local_dir)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
if json_output:
|
||||
@@ -450,111 +442,141 @@ def _run_config_file_pull(*, names: list[str] | None, local_dir: str | None, jso
|
||||
|
||||
|
||||
def _run_config_note_pull(*, local_path: str | None) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
config_module = _config_module()
|
||||
try:
|
||||
path = pull_config_note_from_environment(local_path=local_path)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
path = config_module.pull_config_note_from_environment(local_path=local_path)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(str(path))
|
||||
|
||||
|
||||
def _run_config_note_push(*, local_path: str | None) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
config_module = _config_module()
|
||||
try:
|
||||
response = push_config_note_from_environment(local_path=local_path)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = config_module.push_config_note_from_environment(local_path=local_path)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(response.model_dump_json())
|
||||
|
||||
|
||||
def _run_config_env_push(*, local_path: str) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
config_module = _config_module()
|
||||
try:
|
||||
response = push_config_env_from_environment(local_path=local_path)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = config_module.push_config_env_from_environment(local_path=local_path)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(response.model_dump_json())
|
||||
|
||||
|
||||
def _run_config_files_push(*, paths: list[str]) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
config_module = _config_module()
|
||||
try:
|
||||
response = push_config_files_from_environment(paths=paths)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = config_module.push_config_files_from_environment(paths=paths)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(response.model_dump_json())
|
||||
|
||||
|
||||
def _run_config_files_delete(*, names: list[str]) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
config_module = _config_module()
|
||||
try:
|
||||
response = delete_config_files_from_environment(names=names)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = config_module.delete_config_files_from_environment(names=names)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(response.model_dump_json())
|
||||
|
||||
|
||||
def _run_config_skills_push(*, paths: list[str]) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
config_module = _config_module()
|
||||
try:
|
||||
response = push_config_skills_from_environment(paths=paths)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = config_module.push_config_skills_from_environment(paths=paths)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(response.model_dump_json())
|
||||
|
||||
|
||||
def _run_config_skills_delete(*, names: list[str]) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
config_module = _config_module()
|
||||
try:
|
||||
response = delete_config_skills_from_environment(names=names)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = config_module.delete_config_skills_from_environment(names=names)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(response.model_dump_json())
|
||||
|
||||
|
||||
def _run_drive_list(*, path_prefix: str, json_output: bool) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
drive_module = _drive_module()
|
||||
try:
|
||||
response = list_drive_manifest_from_environment(prefix=path_prefix)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = drive_module.list_drive_manifest_from_environment(prefix=path_prefix)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
if json_output:
|
||||
typer.echo(response.model_dump_json())
|
||||
return
|
||||
typer.echo(format_drive_manifest(response))
|
||||
typer.echo(drive_module.format_drive_manifest(response))
|
||||
|
||||
|
||||
def _run_drive_pull(*, targets: list[str] | None, local_base: str | None, json_output: bool) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
drive_module = _drive_module()
|
||||
try:
|
||||
response = pull_drive_from_environment(targets=targets, local_base=local_base or read_agent_stub_drive_base())
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
response = drive_module.pull_drive_from_environment(
|
||||
targets=targets,
|
||||
local_base=local_base or env_module.read_agent_stub_drive_base(),
|
||||
)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
if json_output:
|
||||
@@ -565,19 +587,54 @@ def _run_drive_pull(*, targets: list[str] | None, local_base: str | None, json_o
|
||||
|
||||
|
||||
def _run_drive_push(*, local_path: str, drive_path: str, kind: str | None) -> None:
|
||||
env_module = _env_module()
|
||||
client_module = _client_module()
|
||||
drive_module = _drive_module()
|
||||
try:
|
||||
response = push_drive_from_environment(
|
||||
response = drive_module.push_drive_from_environment(
|
||||
local_path=local_path,
|
||||
drive_path=drive_path,
|
||||
kind=cast(DrivePushKind | None, kind),
|
||||
kind=kind,
|
||||
)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
except client_module.AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(response.model_dump_json())
|
||||
|
||||
|
||||
# Keep helper imports on demand so importing CLI/help stays free of server-side
|
||||
# and unrelated heavy runtime dependencies.
|
||||
@cache
|
||||
def _agent_stub_module():
|
||||
return import_module("dify_agent.agent_stub.cli._agent_stub")
|
||||
|
||||
|
||||
@cache
|
||||
def _config_module():
|
||||
return import_module("dify_agent.agent_stub.cli._config")
|
||||
|
||||
|
||||
@cache
|
||||
def _drive_module():
|
||||
return import_module("dify_agent.agent_stub.cli._drive")
|
||||
|
||||
|
||||
@cache
|
||||
def _files_module():
|
||||
return import_module("dify_agent.agent_stub.cli._files")
|
||||
|
||||
|
||||
@cache
|
||||
def _env_module():
|
||||
return import_module("dify_agent.agent_stub.cli._env")
|
||||
|
||||
|
||||
@cache
|
||||
def _client_module():
|
||||
return import_module("dify_agent.agent_stub.client")
|
||||
|
||||
|
||||
__all__ = ["app", "main"]
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
from ._agent_stub import (
|
||||
connect_agent_stub_sync,
|
||||
download_file_bytes_from_signed_url_sync,
|
||||
request_agent_stub_config_env_update_sync,
|
||||
request_agent_stub_config_file_pull_sync,
|
||||
request_agent_stub_config_manifest_sync,
|
||||
request_agent_stub_config_note_update_sync,
|
||||
request_agent_stub_config_push_sync,
|
||||
request_agent_stub_config_skill_inspect_sync,
|
||||
request_agent_stub_config_skill_pull_sync,
|
||||
request_agent_stub_drive_commit_sync,
|
||||
request_agent_stub_drive_manifest_sync,
|
||||
request_agent_stub_file_download_sync,
|
||||
request_agent_stub_file_upload_sync,
|
||||
upload_file_to_signed_url_sync,
|
||||
@@ -25,6 +34,15 @@ __all__ = [
|
||||
"AgentStubValidationError",
|
||||
"connect_agent_stub_sync",
|
||||
"download_file_bytes_from_signed_url_sync",
|
||||
"request_agent_stub_config_env_update_sync",
|
||||
"request_agent_stub_config_file_pull_sync",
|
||||
"request_agent_stub_config_manifest_sync",
|
||||
"request_agent_stub_config_note_update_sync",
|
||||
"request_agent_stub_config_push_sync",
|
||||
"request_agent_stub_config_skill_inspect_sync",
|
||||
"request_agent_stub_config_skill_pull_sync",
|
||||
"request_agent_stub_drive_commit_sync",
|
||||
"request_agent_stub_drive_manifest_sync",
|
||||
"request_agent_stub_file_download_sync",
|
||||
"request_agent_stub_file_upload_sync",
|
||||
"upload_file_to_signed_url_sync",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Client-safe protocol exports for the Dify Agent Stub package."""
|
||||
|
||||
from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
|
||||
|
||||
from .agent_stub import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR,
|
||||
AGENT_STUB_PROTOCOL_VERSION,
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR,
|
||||
DEFAULT_AGENT_STUB_DRIVE_BASE,
|
||||
AgentStubConnectRequest,
|
||||
AgentStubConnectResponse,
|
||||
AgentStubConfigEnvUpdateRequest,
|
||||
|
||||
@@ -17,12 +17,12 @@ from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
||||
|
||||
from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
|
||||
|
||||
|
||||
AGENT_STUB_PROTOCOL_VERSION: Final[int] = 1
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_API_BASE_URL"
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_AUTH_JWE"
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_DRIVE_BASE"
|
||||
DEFAULT_AGENT_STUB_DRIVE_BASE: Final[str] = "/mnt/drive"
|
||||
|
||||
type AgentStubURLScheme = Literal["http", "https", "grpc"]
|
||||
|
||||
|
||||
+7
-6
@@ -1,19 +1,20 @@
|
||||
"""Server-side environment injection helpers for Agent Stub forwarding.
|
||||
"""Client-safe shell environment helpers for Agent Stub forwarding.
|
||||
|
||||
Only user-visible ``shell.run`` commands receive these variables. Internal
|
||||
lifecycle commands remain free of Agent Stub credentials and drive-base defaults
|
||||
so workspace setup and cleanup cannot accidentally inherit user-facing forwarding
|
||||
state.
|
||||
lifecycle commands remain free of Agent Stub credentials and drive-base
|
||||
defaults so workspace setup and cleanup cannot accidentally inherit
|
||||
user-facing forwarding state. The module stays server-extra-free because the
|
||||
shell runtime and provider factory use it in sandbox-visible paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR,
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR,
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
agent_stub_drive_base_for_ref,
|
||||
normalize_agent_stub_api_base_url,
|
||||
)
|
||||
@@ -28,7 +28,6 @@ from pydantic_ai import Tool
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import (
|
||||
EmptyLayerConfig,
|
||||
EmptyRuntimeState,
|
||||
LayerDeps,
|
||||
NoLayerDeps,
|
||||
@@ -45,17 +44,11 @@ from dify_agent.adapters.shell.protocols import (
|
||||
ShellProviderProtocol,
|
||||
ShellResourceProtocol,
|
||||
)
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
|
||||
from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.output_text import normalized_output_text, utf8_prefix, utf8_suffix
|
||||
|
||||
try:
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
except ModuleNotFoundError:
|
||||
|
||||
class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, EmptyRuntimeState]):
|
||||
"""Minimal fallback for shell-only imports without server extras installed."""
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -172,7 +165,7 @@ type ShellInterruptToolResult = str | ShellToolErrorObservation
|
||||
|
||||
|
||||
class DifyShellLayerDeps(LayerDeps):
|
||||
execution_context: DifyExecutionContextLayer | None # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
execution_context: PlainLayer[NoLayerDeps, DifyExecutionContextLayerConfig, EmptyRuntimeState] | None # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
class DifyShellRuntimeState(BaseModel):
|
||||
|
||||
@@ -17,7 +17,7 @@ plugin/knowledge business-layer family:
|
||||
Public DTOs provide Dify context plus plugin/model/tool data, while server-only
|
||||
plugin daemon settings and Dify API inner settings are injected through provider
|
||||
factories. Optional shellctl entrypoint/auth token and Agent Stub URL/token
|
||||
issuer are injected for ``DifyShellLayer``. The resulting ``Compositor``
|
||||
factory are injected for ``DifyShellLayer``. The resulting ``Compositor``
|
||||
remains Agenton state-only at the snapshot boundary: live resources such as
|
||||
HTTP clients are injected by runtime-owned providers, may be held on active
|
||||
layer instances inside ``resource_context()``, and never enter session
|
||||
@@ -27,7 +27,7 @@ snapshots.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic_ai.messages import UserContent
|
||||
|
||||
@@ -36,7 +36,7 @@ from agenton.layers.types import AllPromptTypes, AllToolTypes, AllUserPromptType
|
||||
from agenton_collections.layers.pydantic_ai import PydanticAIHistoryLayer
|
||||
from agenton_collections.layers.plain.basic import PromptLayer
|
||||
from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory
|
||||
from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory
|
||||
from dify_agent.layers.ask_human.layer import DifyAskHumanLayer
|
||||
from dify_agent.layers.config.layer import DifyConfigLayer
|
||||
from dify_agent.layers.dify_core_tools.configs import DifyCoreToolsLayerConfig
|
||||
@@ -50,14 +50,9 @@ from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer
|
||||
from dify_agent.layers.output.output_layer import DifyOutputLayer
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.factory import create_shell_provider
|
||||
from dify_agent.layers.shell.configs import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
|
||||
type DifyAgentLayerProvider = LayerProvider[Any]
|
||||
|
||||
|
||||
@@ -70,7 +65,7 @@ def create_default_layer_providers(
|
||||
shellctl_entrypoint: str | None = None,
|
||||
shellctl_auth_token: str | None = None,
|
||||
agent_stub_api_base_url: str | None = None,
|
||||
agent_stub_token_codec: AgentStubTokenCodec | None = None,
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None,
|
||||
) -> tuple[DifyAgentLayerProvider, ...]:
|
||||
"""Return the server provider set of safe config-constructible layers.
|
||||
|
||||
@@ -79,20 +74,9 @@ def create_default_layer_providers(
|
||||
``SHELLCTL_AUTH_TOKEN`` environment variable; deployments that enable
|
||||
shellctl bearer auth must set the Dify Agent server setting explicitly.
|
||||
"""
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
|
||||
if agent_stub_token_codec is not None:
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.factory import create_shell_provider
|
||||
|
||||
def build_agent_stub_token(
|
||||
execution_context: DifyExecutionContextLayerConfig,
|
||||
*,
|
||||
session_id: str | None,
|
||||
) -> str:
|
||||
return agent_stub_token_codec.encode_connection_token(
|
||||
execution_context,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
agent_stub_token_factory = build_agent_stub_token
|
||||
shell_provider = (
|
||||
create_shell_provider(
|
||||
ShellAdapterSettings(
|
||||
|
||||
@@ -22,9 +22,11 @@ import httpx
|
||||
from fastapi import FastAPI
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory
|
||||
from dify_agent.agent_stub.protocol.agent_stub import parse_agent_stub_endpoint
|
||||
from dify_agent.agent_stub.server.grpc_runtime import start_agent_stub_grpc_server
|
||||
from dify_agent.agent_stub.server.router import create_agent_stub_router
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.run_scheduler import RunScheduler
|
||||
from dify_agent.server.observability import configure_server_observability
|
||||
@@ -39,6 +41,21 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
"""Build the FastAPI app with one shared Redis store and local scheduler."""
|
||||
resolved_settings = settings or ServerSettings()
|
||||
agent_stub_token_codec = resolved_settings.create_agent_stub_token_codec()
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
|
||||
if agent_stub_token_codec is not None:
|
||||
# Runtime receives only this callable boundary; router and gRPC wiring
|
||||
# keep the concrete token codec on the server side.
|
||||
def issue_agent_stub_token(
|
||||
execution_context: DifyExecutionContextLayerConfig,
|
||||
*,
|
||||
session_id: str | None,
|
||||
) -> str:
|
||||
return agent_stub_token_codec.encode_connection_token(
|
||||
execution_context,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
agent_stub_token_factory = issue_agent_stub_token
|
||||
agent_stub_file_request_handler = resolved_settings.create_agent_stub_file_request_handler()
|
||||
agent_stub_config_request_handler = resolved_settings.create_agent_stub_config_request_handler()
|
||||
agent_stub_drive_request_handler = resolved_settings.create_agent_stub_drive_request_handler()
|
||||
@@ -50,7 +67,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
shellctl_entrypoint=resolved_settings.shellctl_entrypoint,
|
||||
shellctl_auth_token=resolved_settings.shellctl_auth_token,
|
||||
agent_stub_api_base_url=resolved_settings.agent_stub_api_base_url,
|
||||
agent_stub_token_codec=agent_stub_token_codec,
|
||||
agent_stub_token_factory=agent_stub_token_factory,
|
||||
)
|
||||
sandbox_file_service = (
|
||||
SandboxFileService(layer_providers=layer_providers) if resolved_settings.shellctl_entrypoint else None
|
||||
|
||||
@@ -242,6 +242,45 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(response.parts[0].part_kind, "text")
|
||||
self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response")
|
||||
|
||||
async def test_request_collapses_text_only_assistant_history_parts_to_string_content(self) -> None:
|
||||
messages = [
|
||||
ModelRequest(parts=[UserPromptPart("initial request")]),
|
||||
ModelResponse(
|
||||
parts=[
|
||||
ThinkingPart(content="plan"),
|
||||
TextPart(content="answer"),
|
||||
]
|
||||
),
|
||||
ModelRequest(parts=[UserPromptPart("follow up")]),
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
payload = json.loads(request.content.decode("utf-8"))
|
||||
prompt_messages = payload["data"]["prompt_messages"]
|
||||
|
||||
self.assertEqual([message["role"] for message in prompt_messages], ["user", "assistant", "user"])
|
||||
self.assertEqual(prompt_messages[1]["content"], "<think>\nplan\n</think>answer")
|
||||
|
||||
return build_stream_response(*single_text_chunk("adapter response", prompt_tokens=11, completion_tokens=7))
|
||||
|
||||
async with self.mock_daemon_stream(httpx.MockTransport(handler)):
|
||||
adapter = DifyLLMAdapterModel(
|
||||
"demo-model",
|
||||
self.make_provider(),
|
||||
model_provider="openai",
|
||||
credentials={"api_key": "secret"},
|
||||
)
|
||||
|
||||
response = await adapter.request(
|
||||
messages,
|
||||
model_settings=None,
|
||||
model_request_parameters=ModelRequestParameters(),
|
||||
)
|
||||
|
||||
self.assertEqual(response.model_name, "demo-model")
|
||||
self.assertEqual(response.parts[0].part_kind, "text")
|
||||
self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response")
|
||||
|
||||
async def test_request_omits_empty_assistant_history_when_response_has_no_content_or_tool_calls(self) -> None:
|
||||
messages = [
|
||||
ModelRequest(parts=[SystemPromptPart("request system"), UserPromptPart("hello")]),
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -38,6 +39,13 @@ def _config_manifest_response() -> AgentStubConfigManifestResponse:
|
||||
)
|
||||
|
||||
|
||||
def _patch_cli_module(monkeypatch: pytest.MonkeyPatch, accessor_name: str, **attrs: object) -> None:
|
||||
monkeypatch.setattr(
|
||||
f"dify_agent.agent_stub.cli.main.{accessor_name}",
|
||||
lambda: SimpleNamespace(**attrs),
|
||||
)
|
||||
|
||||
|
||||
def test_cli_connect_reports_missing_environment_variables(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["connect"])
|
||||
@@ -59,7 +67,7 @@ def test_cli_connect_supports_json_output(
|
||||
assert argv == ["echo", "hello"]
|
||||
return AgentStubConnectResponse(connection_id="conn-1", status="connected")
|
||||
|
||||
monkeypatch.setattr("dify_agent.agent_stub.cli.main.connect_from_environment", fake_connect_from_environment)
|
||||
_patch_cli_module(monkeypatch, "_agent_stub_module", connect_from_environment=fake_connect_from_environment)
|
||||
|
||||
main(["connect", "--json", "--", "echo", "hello"])
|
||||
|
||||
@@ -78,7 +86,7 @@ def test_cli_unknown_command_auto_forwards_when_agent_stub_env_is_present(
|
||||
assert argv == ["run", "--target", "prod"]
|
||||
return AgentStubConnectResponse(connection_id="conn-1", status="connected")
|
||||
|
||||
monkeypatch.setattr("dify_agent.agent_stub.cli.main.connect_from_environment", fake_connect_from_environment)
|
||||
_patch_cli_module(monkeypatch, "_agent_stub_module", connect_from_environment=fake_connect_from_environment)
|
||||
|
||||
main(["run", "--target", "prod"])
|
||||
|
||||
@@ -220,7 +228,7 @@ def test_cli_connect_accepts_grpc_agent_stub_api_base_url(
|
||||
assert argv == ["echo", "hello"]
|
||||
return AgentStubConnectResponse(connection_id="conn-1", status="connected")
|
||||
|
||||
monkeypatch.setattr("dify_agent.agent_stub.cli.main.connect_from_environment", fake_connect_from_environment)
|
||||
_patch_cli_module(monkeypatch, "_agent_stub_module", connect_from_environment=fake_connect_from_environment)
|
||||
|
||||
main(["connect", "echo", "hello"])
|
||||
|
||||
@@ -232,9 +240,10 @@ def test_cli_config_manifest_omits_hash_fields(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.manifest_from_environment",
|
||||
lambda: AgentStubConfigManifestResponse(
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_config_module",
|
||||
manifest_from_environment=lambda: AgentStubConfigManifestResponse(
|
||||
agent_id="agent-1",
|
||||
config_version=AgentStubConfigVersionInfo(id="cfg-1", kind="build_draft", writable=True),
|
||||
skills=AgentStubConfigSkillItemsResponse(
|
||||
@@ -335,7 +344,7 @@ def test_cli_config_mutation_commands_forward_and_print_manifest_json(
|
||||
captured_kwargs.update(kwargs)
|
||||
return _config_manifest_response()
|
||||
|
||||
monkeypatch.setattr(f"dify_agent.agent_stub.cli.main.{helper_name}", fake_helper)
|
||||
_patch_cli_module(monkeypatch, "_config_module", **{helper_name: fake_helper})
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(argv)
|
||||
@@ -394,7 +403,7 @@ def test_cli_config_pull_commands_support_plural_and_hidden_singular_aliases(
|
||||
captured_kwargs["local_dir"] = local_dir
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(f"dify_agent.agent_stub.cli.main.{helper_name}", fake_helper)
|
||||
_patch_cli_module(monkeypatch, "_config_module", **{helper_name: fake_helper})
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(argv)
|
||||
@@ -430,9 +439,10 @@ def test_cli_file_upload_prints_uploaded_tool_file_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.upload_file_from_environment",
|
||||
lambda *, path: type(
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_files_module",
|
||||
upload_file_from_environment=lambda *, path: type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
@@ -461,9 +471,10 @@ def test_cli_file_download_prints_saved_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.download_file_from_environment",
|
||||
lambda **_kwargs: type("Response", (), {"path": Path("/tmp/report.pdf")})(),
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_files_module",
|
||||
download_file_from_environment=lambda **_kwargs: type("Response", (), {"path": Path("/tmp/report.pdf")})(),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
@@ -484,8 +495,10 @@ def test_cli_file_download_supports_mapping_json(
|
||||
captured_kwargs.update(kwargs)
|
||||
return type("Response", (), {"path": Path("/tmp/inputs/report.pdf")})()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.download_file_from_environment", fake_download_file_from_environment
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_files_module",
|
||||
download_file_from_environment=fake_download_file_from_environment,
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
@@ -522,8 +535,10 @@ def test_cli_file_download_rejects_legacy_positional_directory(
|
||||
called = True
|
||||
return type("Response", (), {"path": Path("/tmp/report.pdf")})()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.download_file_from_environment", fake_download_file_from_environment
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_files_module",
|
||||
download_file_from_environment=fake_download_file_from_environment,
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
@@ -539,9 +554,10 @@ def test_cli_drive_list_prints_manifest_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.list_drive_manifest_from_environment",
|
||||
lambda *, prefix: AgentStubDriveManifestResponse(
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_drive_module",
|
||||
list_drive_manifest_from_environment=lambda *, prefix: AgentStubDriveManifestResponse(
|
||||
items=[
|
||||
AgentStubDriveItem(
|
||||
key=prefix + "example/SKILL.md",
|
||||
@@ -553,6 +569,10 @@ def test_cli_drive_list_prints_manifest_json(
|
||||
)
|
||||
]
|
||||
),
|
||||
format_drive_manifest=lambda response: (
|
||||
f"{response.items[0].size}\t{response.items[0].mime_type}\t{response.items[0].hash or '-'}\t"
|
||||
f"{response.items[0].key}"
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
@@ -567,9 +587,10 @@ def test_cli_drive_list_prints_human_readable_listing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.list_drive_manifest_from_environment",
|
||||
lambda *, prefix: AgentStubDriveManifestResponse(
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_drive_module",
|
||||
list_drive_manifest_from_environment=lambda *, prefix: AgentStubDriveManifestResponse(
|
||||
items=[
|
||||
AgentStubDriveItem(
|
||||
key=f"{prefix}example/SKILL.md",
|
||||
@@ -581,6 +602,10 @@ def test_cli_drive_list_prints_human_readable_listing(
|
||||
)
|
||||
]
|
||||
),
|
||||
format_drive_manifest=lambda response: (
|
||||
f"{response.items[0].size}\t{response.items[0].mime_type}\t{response.items[0].hash or '-'}\t"
|
||||
f"{response.items[0].key}"
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
@@ -595,9 +620,10 @@ def test_cli_drive_pull_prints_downloaded_paths(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
|
||||
lambda *, targets, local_base: DrivePullResult(
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_drive_module",
|
||||
pull_drive_from_environment=lambda *, targets, local_base: DrivePullResult(
|
||||
items=[
|
||||
DrivePullResult.Item(
|
||||
key=f"{targets[0]}/SKILL.md", local_path=str(Path(local_base) / targets[0] / "SKILL.md")
|
||||
@@ -624,9 +650,10 @@ def test_cli_drive_pull_prints_json_result(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
|
||||
lambda *, targets, local_base: DrivePullResult(
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_drive_module",
|
||||
pull_drive_from_environment=lambda *, targets, local_base: DrivePullResult(
|
||||
items=[
|
||||
DrivePullResult.Item(key="files/a.txt", local_path=f"{local_base}/files/a.txt"),
|
||||
DrivePullResult.Item(key="skills/foo/SKILL.md", local_path=f"{local_base}/skills/foo/SKILL.md"),
|
||||
@@ -664,10 +691,7 @@ def test_cli_drive_pull_forwards_multiple_targets(
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
|
||||
fake_pull_drive_from_environment,
|
||||
)
|
||||
_patch_cli_module(monkeypatch, "_drive_module", pull_drive_from_environment=fake_pull_drive_from_environment)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["drive", "pull", "skills/foo", "files/a.txt", "--to", "/tmp/drive"])
|
||||
@@ -696,10 +720,7 @@ def test_cli_drive_pull_uses_environment_drive_base_default(
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
|
||||
fake_pull_drive_from_environment,
|
||||
)
|
||||
_patch_cli_module(monkeypatch, "_drive_module", pull_drive_from_environment=fake_pull_drive_from_environment)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["drive", "pull", "skills/foo"])
|
||||
@@ -728,10 +749,7 @@ def test_cli_drive_pull_keeps_historical_drive_base_when_env_is_missing(
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
|
||||
fake_pull_drive_from_environment,
|
||||
)
|
||||
_patch_cli_module(monkeypatch, "_drive_module", pull_drive_from_environment=fake_pull_drive_from_environment)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["drive", "pull", "skills/foo"])
|
||||
@@ -755,10 +773,7 @@ def test_cli_drive_pull_without_targets_pulls_whole_visible_drive(
|
||||
items=[DrivePullResult.Item(key="files/a.txt", local_path=str(Path(local_base) / "files" / "a.txt"))]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
|
||||
fake_pull_drive_from_environment,
|
||||
)
|
||||
_patch_cli_module(monkeypatch, "_drive_module", pull_drive_from_environment=fake_pull_drive_from_environment)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["drive", "pull", "--to", "/tmp/drive"])
|
||||
@@ -773,9 +788,10 @@ def test_cli_drive_push_prints_commit_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.push_drive_from_environment",
|
||||
lambda *, local_path, drive_path, kind: AgentStubDriveCommitResponse(
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_drive_module",
|
||||
push_drive_from_environment=lambda *, local_path, drive_path, kind: AgentStubDriveCommitResponse(
|
||||
items=[
|
||||
AgentStubDriveItem(
|
||||
key=drive_path,
|
||||
@@ -810,10 +826,7 @@ def test_cli_drive_push_forwards_kind(
|
||||
captured_kwargs["kind"] = kind
|
||||
return AgentStubDriveCommitResponse(items=[])
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.push_drive_from_environment",
|
||||
fake_push_drive_from_environment,
|
||||
)
|
||||
_patch_cli_module(monkeypatch, "_drive_module", push_drive_from_environment=fake_push_drive_from_environment)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["drive", "push", "/tmp/skill", "skills/example", "--kind", "skill"])
|
||||
@@ -839,10 +852,7 @@ def test_cli_drive_push_accepts_json_flag(
|
||||
captured_kwargs["kind"] = kind
|
||||
return AgentStubDriveCommitResponse(items=[])
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.push_drive_from_environment",
|
||||
fake_push_drive_from_environment,
|
||||
)
|
||||
_patch_cli_module(monkeypatch, "_drive_module", push_drive_from_environment=fake_push_drive_from_environment)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["drive", "push", "/tmp/report.md", "files/report.md", "--json"])
|
||||
@@ -868,10 +878,7 @@ def test_cli_drive_push_rejects_recursive_option(
|
||||
called = True
|
||||
return AgentStubDriveCommitResponse(items=[])
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.push_drive_from_environment",
|
||||
fake_push_drive_from_environment,
|
||||
)
|
||||
_patch_cli_module(monkeypatch, "_drive_module", push_drive_from_environment=fake_push_drive_from_environment)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["drive", "push", "/tmp/dir", "files/dir", "--recursive"])
|
||||
|
||||
@@ -85,7 +85,6 @@ if "jsonschema" not in sys.modules:
|
||||
sys.modules["jsonschema.protocols"] = jsonschema_protocols_module
|
||||
sys.modules["jsonschema.validators"] = jsonschema_validators_module
|
||||
|
||||
import dify_agent.runtime.compositor_factory as compositor_factory_module
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.protocols import ShellProviderProtocol
|
||||
from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, DifyCoreToolsLayerConfig
|
||||
@@ -113,7 +112,7 @@ def test_default_layer_providers_register_shell_layer_with_configured_token_fact
|
||||
captured_settings.append(settings)
|
||||
return cast(ShellProviderProtocol, fake_provider)
|
||||
|
||||
monkeypatch.setattr(compositor_factory_module, "create_shell_provider", fake_create_shell_provider)
|
||||
monkeypatch.setattr("dify_agent.adapters.shell.factory.create_shell_provider", fake_create_shell_provider)
|
||||
|
||||
providers = create_default_layer_providers(
|
||||
shellctl_entrypoint="http://shellctl.example",
|
||||
@@ -138,7 +137,7 @@ def test_default_layer_providers_keep_empty_shellctl_token_by_default(
|
||||
captured_settings.append(settings)
|
||||
return cast(ShellProviderProtocol, FakeProvider())
|
||||
|
||||
monkeypatch.setattr(compositor_factory_module, "create_shell_provider", fake_create_shell_provider)
|
||||
monkeypatch.setattr("dify_agent.adapters.shell.factory.create_shell_provider", fake_create_shell_provider)
|
||||
|
||||
providers = create_default_layer_providers(shellctl_entrypoint="http://shellctl.example")
|
||||
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
|
||||
@@ -153,18 +152,21 @@ def test_shell_provider_rejects_blank_settings_entrypoint_when_default_providers
|
||||
_ = create_default_layer_providers(shellctl_entrypoint=" ")
|
||||
|
||||
|
||||
def test_default_layer_providers_build_agent_stub_token_factory_from_agent_stub_codec() -> None:
|
||||
AgentStubTokenCodec = pytest.importorskip(
|
||||
"dify_agent.agent_stub.server.tokens.agent_stub",
|
||||
reason="jwcrypto is not available in this local test environment",
|
||||
).AgentStubTokenCodec
|
||||
def test_default_layer_providers_forward_agent_stub_token_factory() -> None:
|
||||
captured_calls: list[tuple[DifyExecutionContextLayerConfig, str | None]] = []
|
||||
|
||||
codec = AgentStubTokenCodec.from_server_secret("MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTE")
|
||||
def build_agent_stub_token(
|
||||
execution_context: DifyExecutionContextLayerConfig,
|
||||
*,
|
||||
session_id: str | None,
|
||||
) -> str:
|
||||
captured_calls.append((execution_context, session_id))
|
||||
return f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
|
||||
providers = create_default_layer_providers(
|
||||
shellctl_entrypoint="http://shellctl.example",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_codec=codec,
|
||||
agent_stub_token_factory=build_agent_stub_token,
|
||||
)
|
||||
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
|
||||
shell_layer = shell_provider.create_layer(DifyShellLayerConfig())
|
||||
@@ -180,8 +182,19 @@ def test_default_layer_providers_build_agent_stub_token_factory_from_agent_stub_
|
||||
session_id="abc12ff",
|
||||
)
|
||||
|
||||
assert isinstance(token, str)
|
||||
assert token
|
||||
assert token == "token-for:tenant-1:abc12ff"
|
||||
assert captured_calls == [
|
||||
(
|
||||
DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
),
|
||||
"abc12ff",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_default_layer_providers_register_core_tools_layer() -> None:
|
||||
|
||||
@@ -227,6 +227,11 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
assert isinstance(shell_layer, DifyShellLayer)
|
||||
assert execution_context_layer.daemon_url == "http://plugin-daemon"
|
||||
assert execution_context_layer.daemon_api_key == "daemon-secret"
|
||||
assert shell_layer.agent_stub_token_factory is not None
|
||||
token = shell_layer.agent_stub_token_factory(_execution_context(), session_id="abc12ff")
|
||||
decoded = settings.create_agent_stub_token_codec().decode_token(token)
|
||||
assert decoded.execution_context == _execution_context()
|
||||
assert decoded.session_id == "abc12ff"
|
||||
knowledge_provider = next(provider for provider in layer_providers if provider.type_id == "dify.knowledge_base")
|
||||
knowledge_layer = knowledge_provider.create_layer(
|
||||
DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, cast
|
||||
@@ -16,11 +17,85 @@ from agenton.compositor import CompositorSessionSnapshot, LayerProvider
|
||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||
from agenton.layers.base import LifecycleState
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlClientProtocol, ShellctlProvider
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import (
|
||||
from dify_agent.agent_stub.shell_env import (
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR,
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR,
|
||||
)
|
||||
|
||||
if "graphon.model_runtime.entities.llm_entities" not in sys.modules:
|
||||
graphon_module = types.ModuleType("graphon")
|
||||
model_runtime_module = types.ModuleType("graphon.model_runtime")
|
||||
entities_module = types.ModuleType("graphon.model_runtime.entities")
|
||||
llm_entities_module = types.ModuleType("graphon.model_runtime.entities.llm_entities")
|
||||
message_entities_module = types.ModuleType("graphon.model_runtime.entities.message_entities")
|
||||
|
||||
llm_entities_module.LLMResultChunk = type("LLMResultChunk", (), {})
|
||||
llm_entities_module.LLMUsage = type("LLMUsage", (), {})
|
||||
|
||||
for name in (
|
||||
"AssistantPromptMessage",
|
||||
"AudioPromptMessageContent",
|
||||
"DocumentPromptMessageContent",
|
||||
"ImagePromptMessageContent",
|
||||
"PromptMessage",
|
||||
"PromptMessageContentUnionTypes",
|
||||
"PromptMessageTool",
|
||||
"SystemPromptMessage",
|
||||
"TextPromptMessageContent",
|
||||
"ToolPromptMessage",
|
||||
"UserPromptMessage",
|
||||
"VideoPromptMessageContent",
|
||||
):
|
||||
setattr(message_entities_module, name, type(name, (), {}))
|
||||
|
||||
sys.modules["graphon"] = graphon_module
|
||||
sys.modules["graphon.model_runtime"] = model_runtime_module
|
||||
sys.modules["graphon.model_runtime.entities"] = entities_module
|
||||
sys.modules["graphon.model_runtime.entities.llm_entities"] = llm_entities_module
|
||||
sys.modules["graphon.model_runtime.entities.message_entities"] = message_entities_module
|
||||
|
||||
graphon_module.model_runtime = model_runtime_module
|
||||
model_runtime_module.entities = entities_module
|
||||
entities_module.llm_entities = llm_entities_module
|
||||
entities_module.message_entities = message_entities_module
|
||||
|
||||
if "jsonschema" not in sys.modules:
|
||||
jsonschema_module = types.ModuleType("jsonschema")
|
||||
jsonschema_exceptions_module = types.ModuleType("jsonschema.exceptions")
|
||||
jsonschema_protocols_module = types.ModuleType("jsonschema.protocols")
|
||||
jsonschema_validators_module = types.ModuleType("jsonschema.validators")
|
||||
|
||||
class _SchemaError(Exception):
|
||||
pass
|
||||
|
||||
class _ValidationError(Exception):
|
||||
path: tuple[object, ...] = ()
|
||||
|
||||
class _Validator:
|
||||
@staticmethod
|
||||
def check_schema(schema):
|
||||
return None
|
||||
|
||||
def __init__(self, schema):
|
||||
self.schema = schema
|
||||
|
||||
def iter_errors(self, value):
|
||||
return iter(())
|
||||
|
||||
def _validator_for(schema):
|
||||
return _Validator
|
||||
|
||||
jsonschema_module.SchemaError = _SchemaError
|
||||
jsonschema_exceptions_module.ValidationError = _ValidationError
|
||||
jsonschema_protocols_module.Validator = _Validator
|
||||
jsonschema_validators_module.validator_for = _validator_for
|
||||
|
||||
sys.modules["jsonschema"] = jsonschema_module
|
||||
sys.modules["jsonschema.exceptions"] = jsonschema_exceptions_module
|
||||
sys.modules["jsonschema.protocols"] = jsonschema_protocols_module
|
||||
sys.modules["jsonschema.validators"] = jsonschema_validators_module
|
||||
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.shell import DifyShellLayerConfig
|
||||
|
||||
@@ -71,6 +71,7 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat
|
||||
agent_stub_client_module = importlib.import_module("dify_agent.agent_stub.client")
|
||||
agent_stub_protocol_module = importlib.import_module("dify_agent.agent_stub.protocol")
|
||||
agent_stub_cli_main_module = importlib.import_module("dify_agent.agent_stub.cli.main")
|
||||
agent_stub_shell_env_module = importlib.import_module("dify_agent.agent_stub.shell_env")
|
||||
shell_module = importlib.import_module("dify_agent.layers.shell")
|
||||
drive_module = importlib.import_module("dify_agent.layers.drive")
|
||||
execution_context_module = importlib.import_module("dify_agent.layers.execution_context")
|
||||
@@ -89,8 +90,11 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat
|
||||
assert protocol_module.RunComposition is not None
|
||||
assert protocol_module.RunLayerSpec is not None
|
||||
assert agent_stub_client_module.connect_agent_stub_sync is not None
|
||||
assert agent_stub_client_module.request_agent_stub_config_manifest_sync is not None
|
||||
assert agent_stub_client_module.request_agent_stub_drive_manifest_sync is not None
|
||||
assert agent_stub_protocol_module.AgentStubConnectRequest is not None
|
||||
assert agent_stub_cli_main_module.main is not None
|
||||
assert agent_stub_shell_env_module.build_shell_agent_stub_env is not None
|
||||
assert shell_module.DifyShellLayerConfig is not None
|
||||
assert drive_module.DifyDriveLayerConfig is not None
|
||||
assert execution_context_module.DifyExecutionContextLayerConfig is not None
|
||||
|
||||
@@ -5,17 +5,26 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _run_import_check(*, blocked_imports: list[str], imports: list[str], assertions: list[str]) -> None:
|
||||
def _run_import_check(
|
||||
*,
|
||||
blocked_imports: list[str],
|
||||
imports: list[str],
|
||||
assertions: list[str],
|
||||
bootstrap: list[str] | None = None,
|
||||
) -> None:
|
||||
python_path = os.pathsep.join([str(PROJECT_ROOT / "src"), os.environ.get("PYTHONPATH", "")])
|
||||
module_aliases = {module_name: module_name.replace(".", "_") for module_name in imports}
|
||||
script = "\n".join(
|
||||
[
|
||||
"import builtins",
|
||||
"import importlib",
|
||||
"import sys",
|
||||
f"blocked_imports = {blocked_imports!r}",
|
||||
f"imports = {imports!r}",
|
||||
f"module_aliases = {module_aliases!r}",
|
||||
@@ -27,6 +36,7 @@ def _run_import_check(*, blocked_imports: list[str], imports: list[str], asserti
|
||||
" raise ModuleNotFoundError(f'blocked import: {name}')",
|
||||
" return original_import(name, globals, locals, fromlist, level)",
|
||||
"builtins.__import__ = guarded_import",
|
||||
*(bootstrap or []),
|
||||
"namespace = {}",
|
||||
"for module_name in imports:",
|
||||
" namespace[module_aliases[module_name]] = importlib.import_module(module_name)",
|
||||
@@ -49,6 +59,23 @@ def _run_import_check(*, blocked_imports: list[str], imports: list[str], asserti
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def _run_python_script(script: str) -> None:
|
||||
python_path = os.pathsep.join([str(PROJECT_ROOT / "src"), os.environ.get("PYTHONPATH", "")])
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = python_path
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_dify_agent_root_import_is_client_safe() -> None:
|
||||
_run_import_check(
|
||||
blocked_imports=[
|
||||
@@ -66,10 +93,8 @@ def test_dify_agent_root_import_is_client_safe() -> None:
|
||||
imports=["dify_agent"],
|
||||
assertions=[
|
||||
"from dify_agent import Client",
|
||||
"assert dify_agent.__all__ == ['Client']",
|
||||
"assert dify_agent.Client is Client",
|
||||
"assert not hasattr(dify_agent, 'DifyLLMAdapterModel')",
|
||||
"assert not hasattr(dify_agent, 'DifyPluginDaemonProvider')",
|
||||
"assert 'Client' in dify_agent.__all__",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -110,14 +135,14 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
|
||||
"dify_agent.layers.shell",
|
||||
],
|
||||
assertions=[
|
||||
"assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')",
|
||||
"assert dify_agent_layers_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', 'DifyDriveLayerConfig', 'DifyDriveSkillConfig']",
|
||||
"assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentConfigVersionKind', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']",
|
||||
"assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']",
|
||||
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']",
|
||||
"assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeDatasetConfig', 'DifyKnowledgeEagerResult', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeQueryConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig', 'DifyKnowledgeRuntimeState', 'DifyKnowledgeSetConfig']",
|
||||
"assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']",
|
||||
"assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellCliToolConfig', 'DifyShellEnvVarConfig', 'DifyShellLayerConfig', 'DifyShellSandboxConfig', 'DifyShellSecretRefConfig']",
|
||||
"assert hasattr(dify_agent_protocol, 'CreateRunRequest')",
|
||||
"assert hasattr(dify_agent_layers_drive, 'DifyDriveLayerConfig')",
|
||||
"assert hasattr(dify_agent_layers_execution_context, 'DifyExecutionContextLayerConfig')",
|
||||
"assert hasattr(dify_agent_layers_ask_human, 'DifyAskHumanLayerConfig')",
|
||||
"assert hasattr(dify_agent_layers_dify_plugin, 'DifyPluginLLMLayerConfig')",
|
||||
"assert hasattr(dify_agent_layers_knowledge, 'DifyKnowledgeBaseLayerConfig')",
|
||||
"assert hasattr(dify_agent_layers_output, 'DifyOutputLayerConfig')",
|
||||
"assert hasattr(dify_agent_layers_shell, 'DifyShellLayerConfig')",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -135,31 +160,122 @@ def test_agent_stub_cli_main_import_is_client_safe() -> None:
|
||||
"redis",
|
||||
"shell_session_manager",
|
||||
],
|
||||
imports=["dify_agent.agent_stub.cli.main"],
|
||||
assertions=["assert hasattr(dify_agent_agent_stub_cli_main, 'main')"],
|
||||
)
|
||||
|
||||
|
||||
def test_agent_stub_client_and_protocol_imports_are_client_safe() -> None:
|
||||
_run_import_check(
|
||||
blocked_imports=[
|
||||
"dify_agent.server",
|
||||
"dify_agent.agent_stub.server",
|
||||
"fastapi",
|
||||
"jwcrypto",
|
||||
"pydantic_settings",
|
||||
"redis",
|
||||
"shell_session_manager",
|
||||
imports=[
|
||||
"dify_agent.agent_stub.client",
|
||||
"dify_agent.agent_stub.protocol",
|
||||
"dify_agent.agent_stub.cli.main",
|
||||
"dify_agent.agent_stub.shell_env",
|
||||
"dify_agent.layers.shell.layer",
|
||||
"dify_agent.runtime.compositor_factory",
|
||||
],
|
||||
imports=["dify_agent.agent_stub.client", "dify_agent.agent_stub.protocol"],
|
||||
assertions=[
|
||||
"assert hasattr(dify_agent_agent_stub_client, 'connect_agent_stub_sync')",
|
||||
"assert hasattr(dify_agent_agent_stub_client, 'request_agent_stub_drive_manifest_sync')",
|
||||
"assert hasattr(dify_agent_agent_stub_protocol, 'AgentStubConnectRequest')",
|
||||
"assert hasattr(dify_agent_agent_stub_cli_main, 'main')",
|
||||
"assert hasattr(dify_agent_agent_stub_shell_env, 'build_shell_agent_stub_env')",
|
||||
"assert hasattr(dify_agent_layers_shell_layer, 'DifyShellLayer')",
|
||||
"assert hasattr(dify_agent_runtime_compositor_factory, 'create_default_layer_providers')",
|
||||
],
|
||||
bootstrap=[
|
||||
"import types",
|
||||
"if 'graphon.model_runtime.entities.llm_entities' not in sys.modules:",
|
||||
" graphon_module = types.ModuleType('graphon')",
|
||||
" model_runtime_module = types.ModuleType('graphon.model_runtime')",
|
||||
" entities_module = types.ModuleType('graphon.model_runtime.entities')",
|
||||
" llm_entities_module = types.ModuleType('graphon.model_runtime.entities.llm_entities')",
|
||||
" message_entities_module = types.ModuleType('graphon.model_runtime.entities.message_entities')",
|
||||
" llm_entities_module.LLMResultChunk = type('LLMResultChunk', (), {})",
|
||||
" llm_entities_module.LLMUsage = type('LLMUsage', (), {})",
|
||||
" for name in ('AssistantPromptMessage', 'AudioPromptMessageContent', 'DocumentPromptMessageContent', 'ImagePromptMessageContent', 'PromptMessage', 'PromptMessageContentUnionTypes', 'PromptMessageTool', 'SystemPromptMessage', 'TextPromptMessageContent', 'ToolPromptMessage', 'UserPromptMessage', 'VideoPromptMessageContent'):",
|
||||
" setattr(message_entities_module, name, type(name, (), {}))",
|
||||
" sys.modules['graphon'] = graphon_module",
|
||||
" sys.modules['graphon.model_runtime'] = model_runtime_module",
|
||||
" sys.modules['graphon.model_runtime.entities'] = entities_module",
|
||||
" sys.modules['graphon.model_runtime.entities.llm_entities'] = llm_entities_module",
|
||||
" sys.modules['graphon.model_runtime.entities.message_entities'] = message_entities_module",
|
||||
" graphon_module.model_runtime = model_runtime_module",
|
||||
" model_runtime_module.entities = entities_module",
|
||||
" entities_module.llm_entities = llm_entities_module",
|
||||
" entities_module.message_entities = message_entities_module",
|
||||
"if 'jsonschema' not in sys.modules:",
|
||||
" jsonschema_module = types.ModuleType('jsonschema')",
|
||||
" jsonschema_exceptions_module = types.ModuleType('jsonschema.exceptions')",
|
||||
" jsonschema_protocols_module = types.ModuleType('jsonschema.protocols')",
|
||||
" jsonschema_validators_module = types.ModuleType('jsonschema.validators')",
|
||||
" class _SchemaError(Exception):",
|
||||
" pass",
|
||||
" class _ValidationError(Exception):",
|
||||
" path = ()",
|
||||
" class _Validator:",
|
||||
" @staticmethod",
|
||||
" def check_schema(schema):",
|
||||
" return None",
|
||||
" def __init__(self, schema):",
|
||||
" self.schema = schema",
|
||||
" def iter_errors(self, value):",
|
||||
" return iter(())",
|
||||
" def _validator_for(schema):",
|
||||
" return _Validator",
|
||||
" jsonschema_module.SchemaError = _SchemaError",
|
||||
" jsonschema_exceptions_module.ValidationError = _ValidationError",
|
||||
" jsonschema_protocols_module.Validator = _Validator",
|
||||
" jsonschema_validators_module.validator_for = _validator_for",
|
||||
" sys.modules['jsonschema'] = jsonschema_module",
|
||||
" sys.modules['jsonschema.exceptions'] = jsonschema_exceptions_module",
|
||||
" sys.modules['jsonschema.protocols'] = jsonschema_protocols_module",
|
||||
" sys.modules['jsonschema.validators'] = jsonschema_validators_module",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_agent_stub_cli_help_render_does_not_load_server_modules() -> None:
|
||||
blocked_modules = [
|
||||
"dify_agent.server",
|
||||
"dify_agent.agent_stub.server",
|
||||
"fastapi",
|
||||
"google.protobuf",
|
||||
"grpclib",
|
||||
"jwcrypto",
|
||||
"pydantic_settings",
|
||||
"redis",
|
||||
"shell_session_manager",
|
||||
]
|
||||
script = "\n".join(
|
||||
[
|
||||
"import click",
|
||||
"import importlib",
|
||||
"import os",
|
||||
"import sys",
|
||||
"from typer.main import get_command",
|
||||
f"blocked_modules = {blocked_modules!r}",
|
||||
'original_disable_plugins = os.environ.get("PYDANTIC_DISABLE_PLUGINS")',
|
||||
'original_disable_plugins_present = "PYDANTIC_DISABLE_PLUGINS" in os.environ',
|
||||
'module = importlib.import_module("dify_agent.agent_stub.cli.main")',
|
||||
"command = get_command(module.app)",
|
||||
"help_text = command.get_help(click.Context(command))",
|
||||
'assert "Forward shell-visible dify-agent commands" in help_text',
|
||||
"if original_disable_plugins_present:",
|
||||
' assert os.environ.get("PYDANTIC_DISABLE_PLUGINS") == original_disable_plugins',
|
||||
"else:",
|
||||
' assert "PYDANTIC_DISABLE_PLUGINS" not in os.environ',
|
||||
"loaded_blocked = sorted(",
|
||||
" name",
|
||||
" for name in sys.modules",
|
||||
' if any(name == blocked or name.startswith(f"{blocked}.") for blocked in blocked_modules)',
|
||||
")",
|
||||
"assert loaded_blocked == [], loaded_blocked",
|
||||
]
|
||||
)
|
||||
_run_python_script(script)
|
||||
|
||||
|
||||
def test_server_settings_import_does_not_import_agent_stub_app() -> None:
|
||||
try:
|
||||
__import__("pydantic_settings")
|
||||
__import__("jwcrypto")
|
||||
except ModuleNotFoundError:
|
||||
pytest.skip("server extras are not installed in this environment")
|
||||
|
||||
_run_import_check(
|
||||
blocked_imports=["dify_agent.agent_stub.server.app"],
|
||||
imports=["dify_agent.server.settings"],
|
||||
|
||||
@@ -43,7 +43,6 @@ Use tags in three layers:
|
||||
- `@full-config-agent` — fixed `E2E New Agent Builder Full Config` Agent dependency.
|
||||
- `@tool-states-agent` — fixed `E2E New Agent Builder Tool States` Agent dependency.
|
||||
- `@oauth-tool-agent` — fixed `E2E Agent With OAuth Tool` Agent dependency for OAuth2 tool credential preservation.
|
||||
- `@file-tree-fixture` — fixed file-tree Agent drive/config-files dependency.
|
||||
- `@dual-retrieval-fixture` — fixed dual Knowledge Retrieval Agent dependency.
|
||||
- `@backend-api-access` — fixed or scenario-owned Backend service API access dependency.
|
||||
- `@published-web-app` — fixed or scenario-owned published Web app access dependency.
|
||||
@@ -206,10 +205,6 @@ Use `the Agent Builder preseeded Agent "{agent}" includes an OAuth2 tool credent
|
||||
|
||||
Use `the Agent Builder preseeded Agent "{agent}" includes the dual retrieval fixture configuration` for the fixed Dual Retrieval Agent prerequisite. It composes the indexed knowledge-base preflight, then reads `/console/api/agent/{agent_id}/composer` to verify `agent_soul.knowledge.sets` includes both an Agent-decide generated query set and a custom user-query set using the fixed custom query.
|
||||
|
||||
Use `the Agent Builder preseeded Agent "{agent}" includes the file tree fixture files` for file-tree display prerequisites. It verifies the Agent drive contains every file from `agentBuilderFileTreeFixtureFiles` through `/console/api/agent/{agent_id}/drive/files?prefix=files/`.
|
||||
|
||||
Use `the Agent Builder preseeded Agent "{agent}" includes the current flat file fixture configuration` for the current Agent Edit Files section. Agent config files are still a flat `config_files` list and reject path separators, so this preflight verifies the fixture file basenames are present in the Agent Soul. Treat this as partial coverage for tree-display requirements until the product supports hierarchical config files in the visible Files section.
|
||||
|
||||
Use `the Agent Builder preseeded Agent "{agent}" has published Web app access` to verify that a fixed Agent is published, Web app access is enabled, and the Agent detail response includes the site token and base URL needed to open the Web app.
|
||||
|
||||
Use `the Agent Builder preseeded Agent "{agent}" is referenced by workflow "{workflow}"` to verify Workflow access prerequisites. It checks both fixed resources exist, then uses `/console/api/agent/{agent_id}/referencing-workflows`, the same Console API used by the Access Point Workflow references table, to verify the workflow references the Agent through at least one published Agent node.
|
||||
|
||||
@@ -40,15 +40,6 @@ Feature: Agent v2 Agent Edit page
|
||||
When I open the preseeded Agent v2 configure page for "E2E New Agent Builder Tool States" from the Agent Roster
|
||||
Then Agent v2 Tool credential error state should be available
|
||||
|
||||
@core @file-tree-fixture
|
||||
Scenario: File fixture entries are visible in the current flat Files list
|
||||
Given I am signed in as the default E2E admin
|
||||
And the Agent Builder preseeded Agent "E2E Agent With File Tree" is available
|
||||
And the Agent Builder preseeded Agent "E2E Agent With File Tree" includes the file tree fixture files
|
||||
And the Agent Builder preseeded Agent "E2E Agent With File Tree" includes the current flat file fixture configuration
|
||||
When I open the preseeded Agent v2 configure page for "E2E Agent With File Tree" from the Agent Roster
|
||||
Then I should see the Agent v2 file fixture entries in the current flat Files list
|
||||
|
||||
@core @dual-retrieval-fixture
|
||||
Scenario: Dual Knowledge Retrieval settings are visible on the Agent Edit page
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
@agent-v2 @authenticated @build
|
||||
Feature: Agent v2 build draft
|
||||
@core
|
||||
Scenario: Build chat is blocked until a model is configured
|
||||
Given I am signed in as the default E2E admin
|
||||
And an Agent v2 test agent has been created via API
|
||||
And the Agent v2 composer draft uses the normal E2E prompt
|
||||
When I open the Agent v2 configure page
|
||||
And I try to generate an Agent v2 Build draft without a model
|
||||
Then Agent v2 Build chat should be blocked until a model is configured
|
||||
And the Agent v2 Build draft should not be checked out
|
||||
|
||||
@external-model @agent-backend-runtime @stable-model
|
||||
Scenario: Generating a Build draft leaves the normal Agent configuration unchanged
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -4,7 +4,7 @@ Feature: Agent Builder preseeded environment
|
||||
Scenario: Agent lifecycle permissions are available
|
||||
Given I am signed in as the default E2E admin
|
||||
And an Agent v2 test agent has been created via API
|
||||
And the Agent v2 composer draft uses the normal E2E prompt
|
||||
And the Agent v2 composer draft is publishable
|
||||
When I open the Agent v2 configure page
|
||||
And I publish the Agent v2 draft
|
||||
Then the Agent v2 draft should be published and up to date
|
||||
@@ -89,11 +89,6 @@ Feature: Agent Builder preseeded environment
|
||||
Given I am signed in as the default E2E admin
|
||||
And the Agent Builder preseeded Agent "E2E Agent With OAuth Tool" includes an OAuth2 tool credential
|
||||
|
||||
@file-tree-fixture
|
||||
Scenario: File tree Agent includes fixture files
|
||||
Given I am signed in as the default E2E admin
|
||||
And the Agent Builder preseeded Agent "E2E Agent With File Tree" includes the file tree fixture files
|
||||
|
||||
@dual-retrieval-fixture
|
||||
Scenario: Dual retrieval Agent is available
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
@agent-v2 @authenticated @publish
|
||||
Feature: Agent v2 publish
|
||||
@core
|
||||
Scenario: Publish is blocked until a model is configured
|
||||
Given I am signed in as the default E2E admin
|
||||
And an Agent v2 test agent has been created via API
|
||||
And the Agent v2 composer draft uses the normal E2E prompt
|
||||
When I open the Agent v2 configure page
|
||||
And I try to publish the Agent v2 draft without a model
|
||||
Then Agent v2 publish should be blocked until a model is configured
|
||||
And the Agent v2 draft should remain unpublished
|
||||
|
||||
@core @stable-model
|
||||
Scenario: Publish a configured Agent v2 draft
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -39,6 +39,21 @@ export async function saveAgentBuildDraft(
|
||||
}
|
||||
}
|
||||
|
||||
export async function agentBuildDraftExists(agentId: string): Promise<boolean> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`)
|
||||
if (response.status() === 404)
|
||||
return false
|
||||
|
||||
await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`)
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAgentBuildDraft(agentId: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,6 @@ export const agentBuilderPreseededResources = {
|
||||
fullConfigAgent: 'E2E New Agent Builder Full Config',
|
||||
toolStatesAgent: 'E2E New Agent Builder Tool States',
|
||||
oauthToolAgent: 'E2E Agent With OAuth Tool',
|
||||
fileTreeAgent: 'E2E Agent With File Tree',
|
||||
dualRetrievalAgent: 'E2E Agent With Dual Retrieval',
|
||||
publishedWebAppAgent: 'E2E Agent Published Web App',
|
||||
backendApiEnabledAgent: 'E2E Agent Backend API Enabled',
|
||||
|
||||
@@ -36,6 +36,11 @@ export const normalAgentSoulConfig: AgentSoulConfig = {
|
||||
},
|
||||
}
|
||||
|
||||
export const publishOnlyAgentModel: AgentModelSelection = {
|
||||
name: 'gpt-5-nano',
|
||||
provider: 'openai',
|
||||
}
|
||||
|
||||
export const updatedAgentSoulConfig: AgentSoulConfig = {
|
||||
prompt: {
|
||||
system_prompt: updatedAgentPrompt,
|
||||
@@ -81,6 +86,13 @@ export function createAgentSoulConfigWithModel(
|
||||
}
|
||||
}
|
||||
|
||||
export function createPublishableAgentSoulConfig(agentSoul: AgentSoulConfig): AgentSoulConfig {
|
||||
if (agentSoul.model)
|
||||
return agentSoul
|
||||
|
||||
return createAgentSoulConfigWithModel(agentSoul, publishOnlyAgentModel)
|
||||
}
|
||||
|
||||
export function createAgentSoulConfigWithKnowledgeDataset(
|
||||
agentSoul: AgentSoulConfig,
|
||||
dataset: AgentKnowledgeDatasetConfig,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from '@dify/contracts/api/console/agent/types.gen'
|
||||
import { createApiContext, expectApiResponseOK } from '../../../support/api'
|
||||
import { assertE2EResourceName, createE2EResourceName } from '../../../support/naming'
|
||||
import { defaultAgentSoulConfig, normalAgentSoulConfig } from './agent-soul'
|
||||
import { createPublishableAgentSoulConfig, defaultAgentSoulConfig, normalAgentSoulConfig } from './agent-soul'
|
||||
|
||||
export type AgentSeed = Pick<
|
||||
AgentAppDetailWithSite,
|
||||
@@ -156,6 +156,12 @@ export async function getAgentComposerDraft(agentId: string): Promise<AgentAppCo
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAgentComposerDraftIsPublishable(agentId: string): Promise<void> {
|
||||
const composer = await getAgentComposerDraft(agentId)
|
||||
if (!composer.agent_soul?.model)
|
||||
await saveAgentComposerDraft(agentId, createPublishableAgentSoulConfig(composer.agent_soul ?? defaultAgentSoulConfig))
|
||||
}
|
||||
|
||||
export async function publishAgent(agentId: string, versionNote = 'E2E publish'): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
@@ -168,3 +174,11 @@ export async function publishAgent(agentId: string, versionNote = 'E2E publish')
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishAgentWithPublishableDraft(
|
||||
agentId: string,
|
||||
versionNote = 'E2E publish',
|
||||
): Promise<void> {
|
||||
await ensureAgentComposerDraftIsPublishable(agentId)
|
||||
await publishAgent(agentId, versionNote)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AgentAppComposerResponse,
|
||||
AgentDriveListResponse,
|
||||
AgentDriveSkillListResponse,
|
||||
AgentSoulConfig,
|
||||
} from '@dify/contracts/api/console/agent/types.gen'
|
||||
@@ -12,11 +11,7 @@ import {
|
||||
agentBuilderFixedInputs,
|
||||
agentBuilderPreseededResources,
|
||||
} from '../agent-builder-resources'
|
||||
import {
|
||||
agentBuilderFileTreeFixtureFileNames,
|
||||
agentBuilderFileTreeFixtureFiles,
|
||||
agentBuilderTestMaterials,
|
||||
} from '../test-materials'
|
||||
import { agentBuilderTestMaterials } from '../test-materials'
|
||||
import {
|
||||
asArray,
|
||||
asRecord,
|
||||
@@ -456,80 +451,3 @@ export async function skipMissingPreseededDualRetrievalAgentConfiguration(
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function skipMissingPreseededAgentFileTreeFixture(
|
||||
world: DifyWorld,
|
||||
agentName: string,
|
||||
): Promise<'skipped' | PreseededResource> {
|
||||
const agent = await skipMissingPreseededAgent(world, agentName)
|
||||
if (agent === 'skipped')
|
||||
return agent
|
||||
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const query = buildQuery({ prefix: 'files/' })
|
||||
const response = await ctx.get(`/console/api/agent/${agent.id}/drive/files?${query}`)
|
||||
await expectApiResponseOK(response, `Check preseeded Agent file tree ${agentName}`)
|
||||
const body = (await response.json()) as AgentDriveListResponse
|
||||
const keys = (body.items ?? []).map(item => item.key)
|
||||
const missingFiles = agentBuilderFileTreeFixtureFiles.filter(
|
||||
filePath =>
|
||||
!keys.some(key => key === `files/${filePath}` || key.endsWith(`/${filePath}`)),
|
||||
)
|
||||
|
||||
if (missingFiles.length > 0) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
`Preseeded Agent "${agentName}" is missing file tree fixture files: ${missingFiles.join(', ')}.`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
kind: 'agent',
|
||||
name: agent.name,
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function skipMissingPreseededAgentFlatFileFixtureConfiguration(
|
||||
world: DifyWorld,
|
||||
agentName: string,
|
||||
): Promise<'skipped' | PreseededResource> {
|
||||
const agent = await skipMissingPreseededAgent(world, agentName)
|
||||
if (agent === 'skipped')
|
||||
return agent
|
||||
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.get(`/console/api/agent/${agent.id}/composer`)
|
||||
await expectApiResponseOK(response, `Check preseeded Agent flat file fixture ${agentName}`)
|
||||
const body = (await response.json()) as AgentAppComposerResponse
|
||||
const configFiles = Array.isArray(body.agent_soul?.config_files)
|
||||
? body.agent_soul.config_files
|
||||
: []
|
||||
const fileNames = configFiles
|
||||
.map(file => (typeof file === 'object' && file !== null && 'name' in file ? file.name : undefined))
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
const missingFiles = agentBuilderFileTreeFixtureFileNames.filter(fileName => !fileNames.includes(fileName))
|
||||
|
||||
if (missingFiles.length > 0) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
`Preseeded Agent "${agentName}" is missing current flat Files fixture configuration: ${missingFiles.join(', ')}. Hierarchical Files display remains blocked until Agent config files support tree paths.`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
kind: 'agent',
|
||||
name: agent.name,
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,14 +989,6 @@ const agentV2FullSeedTasks = (): SeedTask[] => [
|
||||
title: agentBuilderPreseededResources.oauthToolAgent,
|
||||
run: seedOAuthToolAgent,
|
||||
},
|
||||
{
|
||||
id: 'file-tree-agent',
|
||||
title: agentBuilderPreseededResources.fileTreeAgent,
|
||||
run: async () => blocked(
|
||||
agentBuilderPreseededResources.fileTreeAgent,
|
||||
'Agent drive arbitrary file upload does not have a stable public seed helper yet.',
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'dual-retrieval-agent',
|
||||
title: agentBuilderPreseededResources.dualRetrievalAgent,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from 'node:path'
|
||||
import { getGeneratedTextMaterialPath, getTestMaterialPath } from '../../../support/test-materials'
|
||||
|
||||
export const agentBuilderTestMaterials = {
|
||||
@@ -11,7 +10,6 @@ export const agentBuilderTestMaterials = {
|
||||
invalidEnv: 'agent-invalid.env',
|
||||
buildInstruction: 'agent-build-instruction.txt',
|
||||
summarySkill: 'e2e-summary-skill/SKILL.md',
|
||||
fileTreeFixture: 'file_tree_fixture',
|
||||
countBatch5: 'count_batch_5_valid_files',
|
||||
countBatch6: 'count_batch_6_valid_files',
|
||||
countTotal50: 'count_total_50_valid_files',
|
||||
@@ -23,17 +21,6 @@ export const agentBuilderGeneratedTestMaterials = {
|
||||
tooLargeFile: 'agent-too-large-file.txt',
|
||||
} as const
|
||||
|
||||
export const agentBuilderFileTreeFixtureFiles = [
|
||||
'assets/sample.csv',
|
||||
'docs/中文说明.md',
|
||||
'public/index.html',
|
||||
'src/main.txt',
|
||||
'web-game/README.md',
|
||||
] as const
|
||||
|
||||
export const agentBuilderFileTreeFixtureFileNames = agentBuilderFileTreeFixtureFiles
|
||||
.map(filePath => path.basename(filePath))
|
||||
|
||||
export const getAgentBuilderTestMaterialPath = (material: keyof typeof agentBuilderTestMaterials) =>
|
||||
getTestMaterialPath(agentBuilderTestMaterials[material])
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
setAgentApiAccess,
|
||||
setAgentSiteAccessAndGetURL,
|
||||
} from '../../agent-v2/support/access-point'
|
||||
import { getAgentAccessPath, publishAgent } from '../../agent-v2/support/agent'
|
||||
import { getAgentAccessPath, publishAgentWithPublishableDraft } from '../../agent-v2/support/agent'
|
||||
import {
|
||||
getAccessRegion,
|
||||
getAccessSurfaceCard,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from './access-point-helpers'
|
||||
|
||||
Given('the Agent v2 draft has been published via API', async function (this: DifyWorld) {
|
||||
await publishAgent(getCurrentAgentId(this))
|
||||
await publishAgentWithPublishableDraft(getCurrentAgentId(this))
|
||||
})
|
||||
|
||||
Given(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
saveAgentComposerDraft,
|
||||
} from '../../agent-v2/support/agent'
|
||||
import {
|
||||
agentBuildDraftExists,
|
||||
applyAgentBuildDraft,
|
||||
saveAgentBuildDraft,
|
||||
} from '../../agent-v2/support/agent-build-draft'
|
||||
@@ -25,6 +26,7 @@ import { hasToolEntry } from '../../agent-v2/support/preflight/tools'
|
||||
import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from '../../agent-v2/support/test-materials'
|
||||
import { getPreseededToolContract } from '../../agent-v2/support/tools'
|
||||
import {
|
||||
expectAgentModelRequiredFeedback,
|
||||
getAgentEnvVariableValue,
|
||||
getCurrentAgentId,
|
||||
uploadSummaryConfigSkillForBuildDraft,
|
||||
@@ -142,6 +144,17 @@ When(
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I try to generate an Agent v2 Build draft without a model',
|
||||
async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
|
||||
await page.getByRole('button', { exact: true, name: 'Build' }).click()
|
||||
await page.getByPlaceholder('Describe what your agent should do').fill('Update the agent instructions for E2E.')
|
||||
await page.getByRole('button', { name: 'Start build' }).click()
|
||||
},
|
||||
)
|
||||
|
||||
const expectPageResponseOK = async (response: Response, action: string) => {
|
||||
if (response.ok())
|
||||
return
|
||||
@@ -246,6 +259,17 @@ Then('I should see the Agent v2 Build mode confirmation state', async function (
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
Then('Agent v2 Build chat should be blocked until a model is configured', async function (this: DifyWorld) {
|
||||
await expectAgentModelRequiredFeedback(this.getPage())
|
||||
})
|
||||
|
||||
Then('the Agent v2 Build draft should not be checked out', async function (this: DifyWorld) {
|
||||
await expect.poll(
|
||||
async () => agentBuildDraftExists(getCurrentAgentId(this)),
|
||||
{ timeout: 30_000 },
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
Then('I should see the e2e-summary-skill Skill in the Skills section', async function (this: DifyWorld) {
|
||||
const skillsSection = this.getPage().getByRole('region', { name: 'Skills' })
|
||||
|
||||
|
||||
@@ -138,6 +138,10 @@ export const expectAgentConfigFileSaved = async (
|
||||
})
|
||||
}
|
||||
|
||||
export const expectAgentModelRequiredFeedback = async (page: ReturnType<DifyWorld['getPage']>) => {
|
||||
await expect(page.getByText('Select your model')).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
export const uploadSummaryConfigSkillForBuildDraft = async (world: DifyWorld) => {
|
||||
const agentId = getCurrentAgentId(world)
|
||||
const skill = await uploadAgentConfigSkillToDraft({
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
concurrentFirstAgentPrompt,
|
||||
concurrentSecondAgentPrompt,
|
||||
createAgentSoulConfigWithModel,
|
||||
createPublishableAgentSoulConfig,
|
||||
normalAgentPrompt,
|
||||
normalAgentSoulConfig,
|
||||
updatedAgentPrompt,
|
||||
@@ -131,6 +132,16 @@ Given('the Agent v2 composer draft uses the normal E2E prompt', async function (
|
||||
await saveAgentComposerDraft(getCurrentAgentId(this), normalAgentSoulConfig)
|
||||
})
|
||||
|
||||
Given(
|
||||
'the Agent v2 composer draft is publishable',
|
||||
async function (this: DifyWorld) {
|
||||
await saveAgentComposerDraft(
|
||||
getCurrentAgentId(this),
|
||||
createPublishableAgentSoulConfig(normalAgentSoulConfig),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Given('the e2e-summary-skill Skill is available to the Agent v2 test agent', async function (this: DifyWorld) {
|
||||
const agentId = getCurrentAgentId(this)
|
||||
const upload = await uploadAgentDriveSkill({
|
||||
|
||||
@@ -2,10 +2,7 @@ import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common'
|
||||
import {
|
||||
agentBuilderFileTreeFixtureFileNames,
|
||||
agentBuilderTestMaterials,
|
||||
} from '../../agent-v2/support/test-materials'
|
||||
import { agentBuilderTestMaterials } from '../../agent-v2/support/test-materials'
|
||||
import {
|
||||
expectAgentConfigFileHidden,
|
||||
expectAgentConfigFileSaved,
|
||||
@@ -70,30 +67,6 @@ Then('I should not see the dropped Agent v2 files in the Files section', async f
|
||||
await expectAgentConfigFileHidden(this, 'emptyFile')
|
||||
})
|
||||
|
||||
Then(
|
||||
'I should see the Agent v2 file fixture entries in the current flat Files list',
|
||||
async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const filesSection = page.getByRole('region', { name: 'Files' })
|
||||
const filesList = filesSection.getByLabel('Agent files')
|
||||
|
||||
await expect(filesSection).toBeVisible({ timeout: 30_000 })
|
||||
await expect(filesList).toBeVisible()
|
||||
|
||||
for (const fileName of agentBuilderFileTreeFixtureFileNames) {
|
||||
await expect(filesList.getByRole('button', {
|
||||
exact: true,
|
||||
name: fileName,
|
||||
})).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(filesList.getByRole('button', { exact: true, name: 'assets' })).toHaveCount(0)
|
||||
await expect(filesList.getByRole('button', { exact: true, name: 'docs' })).toHaveCount(0)
|
||||
await expect(filesList.getByRole('button', { exact: true, name: 'public' })).toHaveCount(0)
|
||||
await expect(filesList.getByRole('button', { exact: true, name: 'src' })).toHaveCount(0)
|
||||
await expect(filesList.getByRole('button', { exact: true, name: 'web-game' })).toHaveCount(0)
|
||||
},
|
||||
)
|
||||
Then('I should see the small Agent v2 file in the Files section', async function (this: DifyWorld) {
|
||||
await expectAgentConfigFileVisible(this, 'smallFile')
|
||||
})
|
||||
|
||||
@@ -9,8 +9,6 @@ import { skipMissingAgentBackendRuntime } from '../../agent-v2/support/preflight
|
||||
import {
|
||||
skipMissingPreseededAgent,
|
||||
skipMissingPreseededAgentDriveSkill,
|
||||
skipMissingPreseededAgentFileTreeFixture,
|
||||
skipMissingPreseededAgentFlatFileFixtureConfiguration,
|
||||
skipMissingPreseededDualRetrievalAgentConfiguration,
|
||||
skipMissingPreseededFullConfigAgentCoreConfiguration,
|
||||
skipMissingPreseededOAuthToolAgentConfiguration,
|
||||
@@ -183,29 +181,6 @@ Given(
|
||||
},
|
||||
)
|
||||
|
||||
Given(
|
||||
'the Agent Builder preseeded Agent {string} includes the file tree fixture files',
|
||||
async function (this: DifyWorld, agentName: string) {
|
||||
const resource = await skipMissingPreseededAgentFileTreeFixture(this, agentName)
|
||||
if (resource === 'skipped')
|
||||
return resource
|
||||
|
||||
this.agentBuilder.preflight.preseededResources[`${agentName} / file tree fixture`] = resource
|
||||
},
|
||||
)
|
||||
|
||||
Given(
|
||||
'the Agent Builder preseeded Agent {string} includes the current flat file fixture configuration',
|
||||
async function (this: DifyWorld, agentName: string) {
|
||||
const resource = await skipMissingPreseededAgentFlatFileFixtureConfiguration(this, agentName)
|
||||
if (resource === 'skipped')
|
||||
return resource
|
||||
|
||||
this.agentBuilder.preflight.preseededResources[`${agentName} / flat file fixture configuration`]
|
||||
= resource
|
||||
},
|
||||
)
|
||||
|
||||
Given(
|
||||
'the Agent Builder preseeded Agent {string} has Backend service API access with an API key',
|
||||
async function (this: DifyWorld, agentName: string) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
|
||||
import { waitForAgentConfigureAutosaved } from '../../../support/agent-configure'
|
||||
import { getAgentVersionDetail, getTestAgent } from '../../agent-v2/support/agent'
|
||||
import { normalAgentPrompt } from '../../agent-v2/support/agent-soul'
|
||||
import { getCurrentAgentId } from './configure-helpers'
|
||||
import { expectAgentModelRequiredFeedback, getCurrentAgentId } from './configure-helpers'
|
||||
|
||||
When('I publish the Agent v2 draft', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
@@ -14,6 +14,25 @@ When('I publish the Agent v2 draft', async function (this: DifyWorld) {
|
||||
await publishButton.click()
|
||||
})
|
||||
|
||||
When('I try to publish the Agent v2 draft without a model', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const publishButton = page.getByRole('button', { name: /^Publish(?: update)?$/ })
|
||||
|
||||
await expect(publishButton).toBeEnabled({ timeout: 30_000 })
|
||||
await publishButton.click()
|
||||
})
|
||||
|
||||
Then('Agent v2 publish should be blocked until a model is configured', async function (this: DifyWorld) {
|
||||
await expectAgentModelRequiredFeedback(this.getPage())
|
||||
})
|
||||
|
||||
Then('the Agent v2 draft should remain unpublished', async function (this: DifyWorld) {
|
||||
await expect.poll(
|
||||
async () => (await getTestAgent(getCurrentAgentId(this))).active_config_is_published,
|
||||
{ timeout: 30_000 },
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
Then('the Agent v2 configuration should be saved automatically', async function (this: DifyWorld) {
|
||||
await waitForAgentConfigureAutosaved(this.getPage())
|
||||
})
|
||||
|
||||
@@ -240,7 +240,6 @@ When(
|
||||
|
||||
await expect(toolsSection).toBeVisible({ timeout: 30_000 })
|
||||
await toolsSection.getByRole('button', { name: 'Add tool' }).click()
|
||||
await this.getPage().getByRole('button', { name: /^Tool\b/ }).click()
|
||||
|
||||
const search = getToolSelectorSearch(this)
|
||||
await expect(search).toBeVisible()
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
name,value
|
||||
alpha,1
|
||||
beta,2
|
||||
|
@@ -1,3 +0,0 @@
|
||||
# 中文说明
|
||||
|
||||
文件树中文说明 token: E2E_FILE_TREE_ZH
|
||||
@@ -1,6 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
E2E_FILE_TREE_INDEX
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Main source fixture token: E2E_FILE_TREE_MAIN
|
||||
@@ -1,3 +0,0 @@
|
||||
# Web Game Fixture
|
||||
|
||||
Expected token: E2E_FILE_TREE_README
|
||||
@@ -1178,11 +1178,11 @@ export const read = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload one Agent App sandbox file as a Dify ToolFile mapping
|
||||
* Upload one Agent App sandbox file and return a signed download URL
|
||||
*/
|
||||
export const post16 = oc
|
||||
.route({
|
||||
description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping',
|
||||
description: 'Upload one Agent App sandbox file and return a signed download URL',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAgentByAgentIdSandboxFilesUpload',
|
||||
|
||||
@@ -442,8 +442,7 @@ export type AgentSandboxUploadPayload = {
|
||||
}
|
||||
|
||||
export type SandboxUploadResponse = {
|
||||
file: SandboxToolFileResponse
|
||||
path: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type AgentSkillUploadResponse = {
|
||||
@@ -1008,11 +1007,6 @@ export type SandboxFileEntryResponse = {
|
||||
type: 'dir' | 'file' | 'other' | 'symlink'
|
||||
}
|
||||
|
||||
export type SandboxToolFileResponse = {
|
||||
reference: string
|
||||
transfer_method?: 'tool_file'
|
||||
}
|
||||
|
||||
export type SkillManifest = {
|
||||
description: string
|
||||
entry_path: string
|
||||
@@ -1116,6 +1110,7 @@ export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'work
|
||||
export type AgentStatus = 'active' | 'archived'
|
||||
|
||||
export type AgentSoulAppFeaturesConfig = {
|
||||
file_upload?: AgentFileUploadFeatureConfig
|
||||
opening_statement?: string | null
|
||||
retriever_resource?: AgentFeatureToggleConfig | null
|
||||
sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null
|
||||
@@ -1428,6 +1423,16 @@ export type AgentConfigRevisionOperation
|
||||
| 'save_new_version'
|
||||
| 'save_to_roster'
|
||||
|
||||
export type AgentFileUploadFeatureConfig = {
|
||||
allowed_file_extensions?: Array<string>
|
||||
allowed_file_types?: Array<FileType>
|
||||
allowed_file_upload_methods?: Array<FileTransferMethod>
|
||||
enabled?: boolean
|
||||
image?: AgentFileUploadImageFeatureConfig
|
||||
number_limits?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentSecretRefConfig = {
|
||||
credential_id?: string | null
|
||||
env_name?: string | null
|
||||
@@ -1679,6 +1684,15 @@ export type FormInputConfig
|
||||
|
||||
export type JsonValue2 = unknown
|
||||
|
||||
export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
|
||||
|
||||
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
|
||||
|
||||
export type AgentFileUploadImageFeatureConfig = {
|
||||
enabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentKnowledgeDatasetConfig = {
|
||||
description?: string | null
|
||||
id?: string | null
|
||||
@@ -1801,10 +1815,6 @@ export type StringListSource = {
|
||||
value?: Array<string>
|
||||
}
|
||||
|
||||
export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
|
||||
|
||||
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
|
||||
|
||||
export type AgentKnowledgeMetadataCondition = {
|
||||
comparison_operator:
|
||||
| '<'
|
||||
|
||||
@@ -212,6 +212,13 @@ export const zAgentSandboxUploadPayload = z.object({
|
||||
path: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* SandboxUploadResponse
|
||||
*/
|
||||
export const zSandboxUploadResponse = z.object({
|
||||
url: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentConfigSnapshotRestoreResponse
|
||||
*/
|
||||
@@ -865,22 +872,6 @@ export const zSandboxListResponse = z.object({
|
||||
truncated: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* SandboxToolFileResponse
|
||||
*/
|
||||
export const zSandboxToolFileResponse = z.object({
|
||||
reference: z.string(),
|
||||
transfer_method: z.literal('tool_file').optional().default('tool_file'),
|
||||
})
|
||||
|
||||
/**
|
||||
* SandboxUploadResponse
|
||||
*/
|
||||
export const zSandboxUploadResponse = z.object({
|
||||
file: zSandboxToolFileResponse,
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillManifest
|
||||
*
|
||||
@@ -1926,19 +1917,6 @@ export const zAgentAppFeaturesPayload = z.object({
|
||||
text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulAppFeaturesConfig
|
||||
*/
|
||||
export const zAgentSoulAppFeaturesConfig = z.object({
|
||||
opening_statement: z.string().nullish(),
|
||||
retriever_resource: zAgentFeatureToggleConfig.nullish(),
|
||||
sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(),
|
||||
speech_to_text: zAgentFeatureToggleConfig.nullish(),
|
||||
suggested_questions: z.array(z.string()).nullish(),
|
||||
suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(),
|
||||
text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(),
|
||||
})
|
||||
|
||||
export const zJsonValue2 = z.unknown()
|
||||
|
||||
/**
|
||||
@@ -1953,6 +1931,54 @@ export const zHumanInputFormSubmissionData = z.object({
|
||||
submitted_data: z.record(z.string(), zJsonValue2).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileType
|
||||
*/
|
||||
export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video'])
|
||||
|
||||
/**
|
||||
* FileTransferMethod
|
||||
*/
|
||||
export const zFileTransferMethod = z.enum([
|
||||
'datasource_file',
|
||||
'local_file',
|
||||
'remote_url',
|
||||
'tool_file',
|
||||
])
|
||||
|
||||
/**
|
||||
* AgentFileUploadImageFeatureConfig
|
||||
*/
|
||||
export const zAgentFileUploadImageFeatureConfig = z.object({
|
||||
enabled: z.boolean().optional().default(true),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentFileUploadFeatureConfig
|
||||
*/
|
||||
export const zAgentFileUploadFeatureConfig = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).optional(),
|
||||
allowed_file_types: z.array(zFileType).optional(),
|
||||
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
image: zAgentFileUploadImageFeatureConfig.optional(),
|
||||
number_limits: z.int().optional().default(3),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulAppFeaturesConfig
|
||||
*/
|
||||
export const zAgentSoulAppFeaturesConfig = z.object({
|
||||
file_upload: zAgentFileUploadFeatureConfig.optional(),
|
||||
opening_statement: z.string().nullish(),
|
||||
retriever_resource: zAgentFeatureToggleConfig.nullish(),
|
||||
sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(),
|
||||
speech_to_text: zAgentFeatureToggleConfig.nullish(),
|
||||
suggested_questions: z.array(z.string()).nullish(),
|
||||
suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(),
|
||||
text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeDatasetConfig
|
||||
*/
|
||||
@@ -2182,6 +2208,29 @@ export const zUserActionConfig = z.object({
|
||||
title: z.string().max(100),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileInputConfig
|
||||
*/
|
||||
export const zFileInputConfig = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).optional(),
|
||||
allowed_file_types: z.array(zFileType).optional(),
|
||||
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
|
||||
output_variable_name: z.string(),
|
||||
type: z.literal('file').optional().default('file'),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileListInputConfig
|
||||
*/
|
||||
export const zFileListInputConfig = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).optional(),
|
||||
allowed_file_types: z.array(zFileType).optional(),
|
||||
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
|
||||
number_limits: z.int().gte(0).optional().default(0),
|
||||
output_variable_name: z.string(),
|
||||
type: z.literal('file-list').optional().default('file-list'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeModelConfig
|
||||
*/
|
||||
@@ -2204,8 +2253,9 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const zAgentKnowledgeQueryConfig = z.object({
|
||||
mode: zAgentKnowledgeQueryMode,
|
||||
@@ -2235,8 +2285,9 @@ export const zAgentKnowledgeWeightedScoreConfig = z.object({
|
||||
* 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.
|
||||
*/
|
||||
export const zAgentKnowledgeRetrievalConfig = z.object({
|
||||
mode: z.enum(['multiple', 'single']),
|
||||
@@ -2249,44 +2300,6 @@ export const zAgentKnowledgeRetrievalConfig = z.object({
|
||||
weights: zAgentKnowledgeWeightedScoreConfig.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileType
|
||||
*/
|
||||
export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video'])
|
||||
|
||||
/**
|
||||
* FileTransferMethod
|
||||
*/
|
||||
export const zFileTransferMethod = z.enum([
|
||||
'datasource_file',
|
||||
'local_file',
|
||||
'remote_url',
|
||||
'tool_file',
|
||||
])
|
||||
|
||||
/**
|
||||
* FileInputConfig
|
||||
*/
|
||||
export const zFileInputConfig = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).optional(),
|
||||
allowed_file_types: z.array(zFileType).optional(),
|
||||
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
|
||||
output_variable_name: z.string(),
|
||||
type: z.literal('file').optional().default('file'),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileListInputConfig
|
||||
*/
|
||||
export const zFileListInputConfig = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).optional(),
|
||||
allowed_file_types: z.array(zFileType).optional(),
|
||||
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
|
||||
number_limits: z.int().gte(0).optional().default(0),
|
||||
output_variable_name: z.string(),
|
||||
type: z.literal('file-list').optional().default('file-list'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeMetadataCondition
|
||||
*/
|
||||
@@ -2331,6 +2344,8 @@ export const zAgentKnowledgeMetadataConditions = z.object({
|
||||
* 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.
|
||||
*/
|
||||
export const zAgentKnowledgeMetadataFilteringConfig = z.object({
|
||||
conditions: zAgentKnowledgeMetadataConditions.nullish(),
|
||||
|
||||
@@ -3029,11 +3029,11 @@ export const read = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload one workflow Agent sandbox file as a Dify ToolFile mapping
|
||||
* Upload one workflow Agent sandbox file and return a signed download URL
|
||||
*/
|
||||
export const post41 = oc
|
||||
.route({
|
||||
description: 'Upload one workflow Agent sandbox file as a Dify ToolFile mapping',
|
||||
description: 'Upload one workflow Agent sandbox file and return a signed download URL',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUpload',
|
||||
|
||||
@@ -873,8 +873,7 @@ export type WorkflowAgentSandboxUploadPayload = {
|
||||
}
|
||||
|
||||
export type SandboxUploadResponse = {
|
||||
file: SandboxToolFileResponse
|
||||
path: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type WorkflowCommentBasicList = {
|
||||
@@ -1807,11 +1806,6 @@ export type SandboxFileEntryResponse = {
|
||||
type: 'dir' | 'file' | 'other' | 'symlink'
|
||||
}
|
||||
|
||||
export type SandboxToolFileResponse = {
|
||||
reference: string
|
||||
transfer_method?: 'tool_file'
|
||||
}
|
||||
|
||||
export type WorkflowCommentBasic = {
|
||||
content: string
|
||||
created_at?: number | null
|
||||
@@ -2368,6 +2362,7 @@ export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'work
|
||||
export type AgentStatus = 'active' | 'archived'
|
||||
|
||||
export type AgentSoulAppFeaturesConfig = {
|
||||
file_upload?: AgentFileUploadFeatureConfig
|
||||
opening_statement?: string | null
|
||||
retriever_resource?: AgentFeatureToggleConfig | null
|
||||
sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null
|
||||
@@ -2627,6 +2622,16 @@ export type WorkflowFileUploadPreviewConfigPayload = {
|
||||
mode?: string | null
|
||||
}
|
||||
|
||||
export type AgentFileUploadFeatureConfig = {
|
||||
allowed_file_extensions?: Array<string>
|
||||
allowed_file_types?: Array<FileType>
|
||||
allowed_file_upload_methods?: Array<FileTransferMethod>
|
||||
enabled?: boolean
|
||||
image?: AgentFileUploadImageFeatureConfig
|
||||
number_limits?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentFeatureToggleConfig = {
|
||||
enabled?: boolean
|
||||
[key: string]: unknown
|
||||
@@ -2873,6 +2878,15 @@ export type FileListInputConfig = {
|
||||
type?: 'file-list'
|
||||
}
|
||||
|
||||
export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
|
||||
|
||||
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
|
||||
|
||||
export type AgentFileUploadImageFeatureConfig = {
|
||||
enabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentModerationProviderConfig = {
|
||||
api_based_extension_id?: string | null
|
||||
inputs_config?: AgentModerationIoConfig | null
|
||||
@@ -2942,10 +2956,6 @@ export type StringListSource = {
|
||||
value?: Array<string>
|
||||
}
|
||||
|
||||
export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
|
||||
|
||||
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
|
||||
|
||||
export type AgentModerationIoConfig = {
|
||||
enabled?: boolean
|
||||
preset_response?: string | null
|
||||
|
||||
@@ -564,6 +564,13 @@ export const zWorkflowAgentSandboxUploadPayload = z.object({
|
||||
path: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* SandboxUploadResponse
|
||||
*/
|
||||
export const zSandboxUploadResponse = z.object({
|
||||
url: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowCommentCreatePayload
|
||||
*/
|
||||
@@ -1673,22 +1680,6 @@ export const zSandboxListResponse = z.object({
|
||||
truncated: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* SandboxToolFileResponse
|
||||
*/
|
||||
export const zSandboxToolFileResponse = z.object({
|
||||
reference: z.string(),
|
||||
transfer_method: z.literal('tool_file').optional().default('tool_file'),
|
||||
})
|
||||
|
||||
/**
|
||||
* SandboxUploadResponse
|
||||
*/
|
||||
export const zSandboxUploadResponse = z.object({
|
||||
file: zSandboxToolFileResponse,
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AccountWithRoleResponse
|
||||
*/
|
||||
@@ -3466,6 +3457,63 @@ export const zUserActionConfig = z.object({
|
||||
title: z.string().max(100),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileType
|
||||
*/
|
||||
export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video'])
|
||||
|
||||
/**
|
||||
* FileTransferMethod
|
||||
*/
|
||||
export const zFileTransferMethod = z.enum([
|
||||
'datasource_file',
|
||||
'local_file',
|
||||
'remote_url',
|
||||
'tool_file',
|
||||
])
|
||||
|
||||
/**
|
||||
* FileInputConfig
|
||||
*/
|
||||
export const zFileInputConfig = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).optional(),
|
||||
allowed_file_types: z.array(zFileType).optional(),
|
||||
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
|
||||
output_variable_name: z.string(),
|
||||
type: z.literal('file').optional().default('file'),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileListInputConfig
|
||||
*/
|
||||
export const zFileListInputConfig = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).optional(),
|
||||
allowed_file_types: z.array(zFileType).optional(),
|
||||
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
|
||||
number_limits: z.int().gte(0).optional().default(0),
|
||||
output_variable_name: z.string(),
|
||||
type: z.literal('file-list').optional().default('file-list'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentFileUploadImageFeatureConfig
|
||||
*/
|
||||
export const zAgentFileUploadImageFeatureConfig = z.object({
|
||||
enabled: z.boolean().optional().default(true),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentFileUploadFeatureConfig
|
||||
*/
|
||||
export const zAgentFileUploadFeatureConfig = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).optional(),
|
||||
allowed_file_types: z.array(zFileType).optional(),
|
||||
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
image: zAgentFileUploadImageFeatureConfig.optional(),
|
||||
number_limits: z.int().optional().default(3),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSuggestedQuestionsAfterAnswerModelConfig
|
||||
*
|
||||
@@ -3662,44 +3710,6 @@ export const zAgentSoulToolsConfig = z.object({
|
||||
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileType
|
||||
*/
|
||||
export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video'])
|
||||
|
||||
/**
|
||||
* FileTransferMethod
|
||||
*/
|
||||
export const zFileTransferMethod = z.enum([
|
||||
'datasource_file',
|
||||
'local_file',
|
||||
'remote_url',
|
||||
'tool_file',
|
||||
])
|
||||
|
||||
/**
|
||||
* FileInputConfig
|
||||
*/
|
||||
export const zFileInputConfig = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).optional(),
|
||||
allowed_file_types: z.array(zFileType).optional(),
|
||||
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
|
||||
output_variable_name: z.string(),
|
||||
type: z.literal('file').optional().default('file'),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileListInputConfig
|
||||
*/
|
||||
export const zFileListInputConfig = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).optional(),
|
||||
allowed_file_types: z.array(zFileType).optional(),
|
||||
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
|
||||
number_limits: z.int().gte(0).optional().default(0),
|
||||
output_variable_name: z.string(),
|
||||
type: z.literal('file-list').optional().default('file-list'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentModerationIOConfig
|
||||
*/
|
||||
@@ -3731,6 +3741,7 @@ export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({
|
||||
* AgentSoulAppFeaturesConfig
|
||||
*/
|
||||
export const zAgentSoulAppFeaturesConfig = z.object({
|
||||
file_upload: zAgentFileUploadFeatureConfig.optional(),
|
||||
opening_statement: z.string().nullish(),
|
||||
retriever_resource: zAgentFeatureToggleConfig.nullish(),
|
||||
sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(),
|
||||
@@ -3762,8 +3773,9 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const zAgentKnowledgeQueryConfig = z.object({
|
||||
mode: zAgentKnowledgeQueryMode,
|
||||
@@ -3793,8 +3805,9 @@ export const zAgentKnowledgeWeightedScoreConfig = z.object({
|
||||
* 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.
|
||||
*/
|
||||
export const zAgentKnowledgeRetrievalConfig = z.object({
|
||||
mode: z.enum(['multiple', 'single']),
|
||||
@@ -3972,6 +3985,8 @@ export const zAgentKnowledgeMetadataConditions = z.object({
|
||||
* 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.
|
||||
*/
|
||||
export const zAgentKnowledgeMetadataFilteringConfig = z.object({
|
||||
conditions: zAgentKnowledgeMetadataConditions.nullish(),
|
||||
|
||||
@@ -315,7 +315,8 @@ export type MessageMetadata = {
|
||||
}
|
||||
|
||||
export type OpenApiErrorCode
|
||||
= | 'app_unavailable'
|
||||
= | 'agent_not_published'
|
||||
| 'app_unavailable'
|
||||
| 'bad_gateway'
|
||||
| 'bad_request'
|
||||
| 'completion_request_error'
|
||||
|
||||
@@ -398,6 +398,7 @@ export const zMemberRoleUpdatePayload = z.object({
|
||||
* OpenApiErrorCode
|
||||
*/
|
||||
export const zOpenApiErrorCode = z.enum([
|
||||
'agent_not_published',
|
||||
'app_unavailable',
|
||||
'bad_gateway',
|
||||
'bad_request',
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { EnableType, OnSend } from '../../types'
|
||||
import type { InputForm } from '../type'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { decode } from 'html-entities'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
@@ -211,25 +211,32 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
|
||||
{shouldShowFooterNotice && (
|
||||
<div className="m-1 mt-0 -translate-y-2 rounded-b-[10px] border-r border-b border-l border-components-panel-border-subtle bg-util-colors-indigo-indigo-50 px-2.5 py-2 pt-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<span aria-hidden className="i-ri-information-line size-3.5 shrink-0 text-text-accent" />
|
||||
<div className="body-xs-medium text-text-accent">{footerNotice}</div>
|
||||
{shouldShowFooterNoticeTooltip && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-5 items-center justify-center rounded-md text-text-accent hover:bg-state-base-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg"
|
||||
className="ml-auto flex size-5 items-center justify-center rounded-md system-xs-medium text-text-accent hover:bg-state-base-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg"
|
||||
aria-label={typeof footerNoticeTooltip === 'string' ? footerNoticeTooltip : undefined}
|
||||
>
|
||||
<span aria-hidden className="i-ri-information-line size-3.5 shrink-0" />
|
||||
<span aria-hidden className="i-ri-question-line size-3.5 shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent className="max-w-80">
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
popupClassName="max-w-80 rounded-md border-0 px-3 py-2 text-start system-xs-regular wrap-break-word text-text-tertiary"
|
||||
>
|
||||
{footerNoticeTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<div className="body-xs-medium text-text-accent">{footerNotice}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user