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:
yyh
2026-07-06 21:51:33 +08:00
committed by GitHub
parent bdb3469ca0
commit d0ea5a5e0d
180 changed files with 4929 additions and 1512 deletions
@@ -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.
+1
View File
@@ -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"
+8 -36
View File
@@ -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:
+6
View File
@@ -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."
+14 -3
View File
@@ -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
+6
View File
@@ -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:
+6
View File
@@ -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"]
+4 -5
View File
@@ -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"]
+5 -5
View File
@@ -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"),
+6
View File
@@ -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."""
+2 -2
View File
@@ -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,
+27 -27
View File
@@ -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):
+29 -15
View File
@@ -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
+5 -4
View File
@@ -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,
+32
View File
@@ -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")
+8
View File
@@ -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."
+70 -6
View File
@@ -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")
@@ -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")
+15 -1
View File
@@ -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
+137 -80
View File
@@ -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"]
@@ -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(
+18 -1
View File
@@ -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"],
-5
View File
@@ -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.
-9
View File
@@ -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
+10
View File
@@ -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
+1 -6
View File
@@ -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
+10
View File
@@ -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,
+15 -1
View File
@@ -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()
}
}
-8
View File
@@ -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 name value
2 alpha 1
3 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