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 13:51:33 +00:00
committed by GitHub
co-authored by Joel zyssyz123 autofix-ci[bot] 林玮 盐粒 Yanli
parent bdb3469ca0
commit d0ea5a5e0d
180 changed files with 4929 additions and 1512 deletions
+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",
]