chore(agent-v2): sync daily changes (#38162)

Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yunlu Wen <wylswz@163.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: Yanli 盐粒 <yanli@dify.ai>
Co-authored-by: 盐粒 Yanli <beautyyuyanli@gmail.com>
Co-authored-by: zyssyz123 <916125788@qq.com>
Co-authored-by: 盐粒 Yanli <mail@yanli.one>
This commit is contained in:
yyh
2026-07-01 05:07:23 +00:00
committed by GitHub
co-authored by yunlu.wen autofix-ci[bot] Yunlu Wen Copilot Autofix powered by AI Joel Yanli 盐粒 盐粒 Yanli zyssyz123 盐粒 Yanli
parent f816ae2e95
commit 0923ebaf88
277 changed files with 26866 additions and 7348 deletions
+4
View File
@@ -32,6 +32,8 @@ from clients.agent_backend.factory import create_agent_backend_run_client
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
from clients.agent_backend.request_builder import (
AGENT_SOUL_PROMPT_LAYER_ID,
DIFY_CONFIG_LAYER_ID,
DIFY_CORE_TOOLS_LAYER_ID,
DIFY_EXECUTION_CONTEXT_LAYER_ID,
DIFY_KNOWLEDGE_BASE_LAYER_ID,
DIFY_PLUGIN_TOOLS_LAYER_ID,
@@ -47,6 +49,8 @@ from clients.agent_backend.request_builder import (
__all__ = [
"AGENT_SOUL_PROMPT_LAYER_ID",
"DIFY_CONFIG_LAYER_ID",
"DIFY_CORE_TOOLS_LAYER_ID",
"DIFY_EXECUTION_CONTEXT_LAYER_ID",
"DIFY_KNOWLEDGE_BASE_LAYER_ID",
"DIFY_PLUGIN_TOOLS_LAYER_ID",
+73 -10
View File
@@ -20,6 +20,8 @@ from agenton.layers import ExitIntent
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig
from dify_agent.layers.config import DIFY_CONFIG_LAYER_TYPE_ID, DifyConfigLayerConfig
from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
@@ -54,8 +56,10 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_CONFIG_LAYER_ID = "config"
DIFY_DRIVE_LAYER_ID = "drive"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
DIFY_CORE_TOOLS_LAYER_ID = "core_tools"
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
DIFY_ASK_HUMAN_LAYER_ID = "ask_human"
DIFY_SHELL_LAYER_ID = "shell"
@@ -86,6 +90,10 @@ def _drive_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _config_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _shell_config_with_drive_ref(
shell_config: DifyShellLayerConfig | None,
drive_config: DifyDriveLayerConfig | None,
@@ -159,7 +167,9 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
tools: DifyPluginToolsLayerConfig | None = None
core_tools: DifyCoreToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
drive_config: DifyDriveLayerConfig | None = None
@@ -206,7 +216,9 @@ class AgentBackendAgentAppRunInput(BaseModel):
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
tools: DifyPluginToolsLayerConfig | None = None
core_tools: DifyCoreToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
drive_config: DifyDriveLayerConfig | None = None
@@ -243,8 +255,9 @@ class AgentBackendRunRequestBuilder:
Layer graph: optional Agent Soul system prompt → user prompt →
execution context → optional history (multi-turn) → LLM → optional
plugin tools / knowledge search → optional structured output. Mirrors the workflow-node
layer ordering minus the workflow-job / previous-node prompt.
plugin-direct tools / core-routed tools / knowledge search →
optional structured output. Mirrors the workflow-node layer ordering
minus the workflow-job / previous-node prompt.
"""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
@@ -274,11 +287,13 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = run_input.include_shell or run_input.drive_config is not None
include_shell = (
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
)
if include_shell:
# Sandboxed bash workspace (dify.shell). It enters before drive so
# drive can materialize mentioned targets with `dify-agent drive pull`
# in the same shell-visible filesystem used by model commands.
# Sandboxed bash workspace (dify.shell). It enters before config/drive
# so eager pulls materialize content in the same filesystem used by
# model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
@@ -289,6 +304,17 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.config_layer_config is not None:
layers.append(
RunLayerSpec(
name=DIFY_CONFIG_LAYER_ID,
type=DIFY_CONFIG_LAYER_TYPE_ID,
deps=_config_layer_deps(),
metadata=run_input.metadata,
config=run_input.config_layer_config,
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
@@ -338,6 +364,17 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.core_tools is not None and run_input.core_tools.tools:
layers.append(
RunLayerSpec(
name=DIFY_CORE_TOOLS_LAYER_ID,
type=DIFY_CORE_TOOLS_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.core_tools,
)
)
if run_input.knowledge is not None and run_input.knowledge.sets:
layers.append(
RunLayerSpec(
@@ -403,7 +440,8 @@ class AgentBackendRunRequestBuilder:
non-plugin layer graph that produced the snapshot. Plugin layers
(``dify.plugin.llm``, ``dify.plugin.tools``) are excluded from both the
composition and the snapshot before submission because their configs
require credentials that are not persisted between runs.
may carry credentials or runtime-only declarations that are not
persisted between runs.
"""
if not runtime_layer_specs:
raise ValueError(
@@ -436,8 +474,9 @@ class AgentBackendRunRequestBuilder:
"""Build a workflow Agent Node run request without defining another wire schema.
Layer graph mirrors the workflow surface: prompts → execution context →
optional drive/history → LLM → optional plugin tools / knowledge search
→ optional auxiliary layers such as ask_human, shell, and structured output.
optional drive/history → LLM → optional plugin-direct tools /
core-routed tools / knowledge search → optional auxiliary layers such
as ask_human, shell, and structured output.
"""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
@@ -473,7 +512,9 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = run_input.include_shell or run_input.drive_config is not None
include_shell = (
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
)
if include_shell:
# Sandboxed bash workspace (dify.shell). It enters before drive so
# drive can materialize mentioned targets with `dify-agent drive pull`
@@ -488,6 +529,17 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.config_layer_config is not None:
layers.append(
RunLayerSpec(
name=DIFY_CONFIG_LAYER_ID,
type=DIFY_CONFIG_LAYER_TYPE_ID,
deps=_config_layer_deps(),
metadata=run_input.metadata,
config=run_input.config_layer_config,
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
@@ -539,6 +591,17 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.core_tools is not None and run_input.core_tools.tools:
layers.append(
RunLayerSpec(
name=DIFY_CORE_TOOLS_LAYER_ID,
type=DIFY_CORE_TOOLS_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.core_tools,
)
)
if run_input.knowledge is not None and run_input.knowledge.sets:
layers.append(
RunLayerSpec(
+2
View File
@@ -54,6 +54,7 @@ from .app import (
agent_app_access,
agent_app_feature,
agent_app_sandbox,
agent_config_inspector,
agent_drive_inspector,
annotation,
app,
@@ -157,6 +158,7 @@ __all__ = [
"agent_app_feature",
"agent_app_sandbox",
"agent_composer",
"agent_config_inspector",
"agent_drive_inspector",
"agent_providers",
"agent_roster",
+3 -1
View File
@@ -57,8 +57,9 @@ class WorkflowAgentComposerApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, node_id: str):
def get(self, tenant_id: str, account_id: str, app_model: App, node_id: str):
query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True))
return dump_response(
WorkflowAgentComposerResponse,
@@ -66,6 +67,7 @@ class WorkflowAgentComposerApi(Resource):
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account_id,
snapshot_id=query.snapshot_id,
),
)
+16 -4
View File
@@ -248,12 +248,16 @@ class AgentAppDetailWithSite(GenericAppDetailWithSite):
backing_app_id: str | None = None
hidden_app_backed: bool = False
debug_conversation_id: str | None = None
debug_conversation_has_messages: bool = False
debug_conversation_message_count: int = 0
role: str | None = None
active_config_is_published: bool = False
class AgentDebugConversationRefreshResponse(BaseModel):
debug_conversation_id: str
debug_conversation_has_messages: bool = False
debug_conversation_message_count: int = 0
class AgentPublishPayload(BaseModel):
@@ -372,11 +376,17 @@ def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: s
payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent)
payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id)
payload["id"] = agent.id
payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id(
debug_conversation_id = roster_service.get_or_create_agent_app_debug_conversation_id(
tenant_id=app_model.tenant_id,
agent_id=agent.id,
account_id=current_user.id,
)
message_count = roster_service.count_agent_app_debug_conversation_messages(
conversation_id=debug_conversation_id,
)
payload["debug_conversation_id"] = debug_conversation_id
payload["debug_conversation_has_messages"] = message_count > 0
payload["debug_conversation_message_count"] = message_count
payload["role"] = agent.role or ""
payload["active_config_is_published"] = roster_service.active_config_is_published(
tenant_id=app_model.tenant_id,
@@ -635,9 +645,11 @@ class AgentDebugConversationRefreshApi(Resource):
agent_id=str(agent_id),
account_id=current_user.id,
)
return AgentDebugConversationRefreshResponse(debug_conversation_id=debug_conversation_id).model_dump(
mode="json"
)
return AgentDebugConversationRefreshResponse(
debug_conversation_id=debug_conversation_id,
debug_conversation_has_messages=False,
debug_conversation_message_count=0,
).model_dump(mode="json")
@console_ns.route("/agent/<uuid:agent_id>/publish")
File diff suppressed because it is too large Load Diff
+174 -6
View File
@@ -1,4 +1,6 @@
import json
import logging
from collections.abc import Generator
from typing import Any, Literal
from uuid import UUID
@@ -34,6 +36,7 @@ from controllers.console.wraps import (
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
from core.errors.error import (
ModelCurrentlyNotSupportError,
ProviderTokenNotInitError,
@@ -106,6 +109,32 @@ class ChatMessagePayload(BaseMessagePayload):
return uuid_value(value)
_BUILD_CHAT_FINALIZATION_QUERY = """Finalize this Build chat configuration for the agent.
This step is only for persisting Agent config changes discovered in the current Build chat. Do not install packages,
edit workspace files, run validation or debugging commands, make exploratory checks, or perform other work.
Use only the current Build chat message history to identify changes that need to be persisted. Do not inspect, test, or
validate old config unless the message history already shows that the old config is invalid.
Persist only the build-draft config resources that need to change, using the Agent config CLI usage provided in the
runtime prompt:
- config files for reusable artifacts that should be available later,
- config skills for reusable procedures or tools that should be available later,
- config env when environment keys or values need to be recorded,
- config note for concise durable context when useful.
When updating the config note, record only durable context needed by later runs, such as:
- what you installed or configured outside the workspace for this agent,
- where those external updates live, including CLI tools, packages, and persistent $HOME paths,
- how the agent should use it in later runs,
- any setup, authentication, or user action still required.
After config persistence completes, respond FINISHED."""
register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)
register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse)
@@ -231,6 +260,31 @@ class AgentChatMessageApi(Resource):
)
@console_ns.route("/agent/<uuid:agent_id>/build-chat/finalize")
class AgentBuildChatFinalizeApi(Resource):
@console_ns.doc("finalize_agent_build_chat")
@console_ns.doc(description="Run a build-draft Agent App turn that asks the agent to push config updates")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(404, "Agent, build draft, or conversation not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _create_build_chat_finalization_message(
current_tenant_id=current_tenant_id,
current_user=current_user,
app_model=app_model,
agent_id=str(agent_id),
)
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<string:task_id>/stop")
class ChatMessageStopApi(Resource):
@console_ns.doc("stop_chat_message")
@@ -284,7 +338,11 @@ def _resolve_current_user_agent_debug_conversation_id(
def _create_chat_message(
*, current_user: Account, app_model: App, current_tenant_id: str | None = None, agent_id: str | None = None
*,
current_user: Account,
app_model: App,
current_tenant_id: str | None = None,
agent_id: str | None = None,
):
raw_payload = console_ns.payload or {}
args_model = ChatMessagePayload.model_validate(raw_payload)
@@ -314,12 +372,104 @@ def _create_chat_message(
if external_trace_id:
args["external_trace_id"] = external_trace_id
try:
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
)
return _generate_chat_message_response(
current_user=current_user,
app_model=app_model,
args=args,
streaming=streaming,
)
return helper.compact_generate_response(response)
def _create_build_chat_finalization_message(
*, current_user: Account, app_model: App, current_tenant_id: str, agent_id: str
):
debug_conversation_id = _resolve_current_user_agent_debug_conversation_id(
current_tenant_id=current_tenant_id,
current_user=current_user,
app_model=app_model,
agent_id=agent_id,
)
args: dict[str, Any] = {
"query": _BUILD_CHAT_FINALIZATION_QUERY,
"inputs": {},
"response_mode": "streaming",
"draft_type": "debug_build",
"conversation_id": debug_conversation_id,
"auto_generate_name": False,
}
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
response = _generate_chat_message(
current_user=current_user,
app_model=app_model,
args=args,
streaming=True,
)
_drain_streaming_generate_response(response)
return {"result": "success"}, 200
def _drain_streaming_generate_response(response: RateLimitGenerator | Generator[str, None, None]) -> None:
"""Consume a streamed app-generate response until a terminal message event arrives.
Finalize keeps the normal Agent App streaming path so the existing queue,
persistence, and runtime-session behavior stay intact. The console API only
changes the HTTP boundary: it drains the SSE stream server-side and returns
success after the generated build-chat message reaches ``message_end``.
"""
close = getattr(response, "close", None)
try:
for chunk in response:
for raw_event in chunk.split("\n\n"):
if not raw_event.strip():
continue
event_name: str | None = None
data_lines: list[str] = []
for line in raw_event.splitlines():
if line.startswith("event: "):
event_name = line.removeprefix("event: ").strip()
elif line.startswith("data: "):
data_lines.append(line.removeprefix("data: "))
if not data_lines:
if event_name == "ping":
continue
continue
payload = json.loads("\n".join(data_lines))
if not isinstance(payload, dict):
continue
payload_event = payload.get("event")
if payload_event == "message_end":
return
if payload_event == "error":
raise CompletionRequestError(str(payload.get("message") or "Build chat finalization failed."))
finally:
if callable(close):
close()
raise CompletionRequestError("Build chat finalization did not complete.")
def _generate_chat_message(
*,
current_user: Account,
app_model: App,
args: dict[str, Any],
streaming: bool,
):
try:
return AppGenerateService.generate(
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.DEBUGGER,
streaming=streaming,
)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
@@ -335,6 +485,8 @@ def _create_chat_message(
raise ProviderModelCurrentlyNotSupportError()
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except CompletionRequestError:
raise
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
@@ -344,6 +496,22 @@ def _create_chat_message(
raise InternalServerError()
def _generate_chat_message_response(
*,
current_user: Account,
app_model: App,
args: dict[str, Any],
streaming: bool,
):
response = _generate_chat_message(
current_user=current_user,
app_model=app_model,
args=args,
streaming=streaming,
)
return helper.compact_generate_response(response)
def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str):
AppTaskService.stop_task(
task_id=task_id,
+4
View File
@@ -17,8 +17,10 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat
from . import mail as _mail
from . import runtime_credentials as _runtime_credentials
from .agent import tools as _agent_tools
from .app import dsl as _app_dsl
from .knowledge import retrieval as _knowledge_retrieval
from .plugin import agent_config as _agent_config
from .plugin import agent_drive as _agent_drive
from .plugin import plugin as _plugin
from .workspace import workspace as _workspace
@@ -26,7 +28,9 @@ from .workspace import workspace as _workspace
api.add_namespace(inner_api_ns)
__all__ = [
"_agent_config",
"_agent_drive",
"_agent_tools",
"_app_dsl",
"_knowledge_retrieval",
"_mail",
@@ -0,0 +1 @@
"""Agent backend inner API controllers."""
+68
View File
@@ -0,0 +1,68 @@
"""Inner API endpoint for Agent core tool invocation."""
from flask_restx import Resource
from pydantic import ValidationError
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import agent_inner_api_only
from extensions.ext_database import db
from libs.exception import BaseHTTPException
from services.agent_tool_inner_service import AgentToolInnerService
from services.entities.agent_tool_inner import AgentToolInvokeRequest, AgentToolInvokeResponse
from services.errors.agent_tool_inner import AgentToolInnerServiceError
class AgentToolInvokeHttpError(BaseHTTPException):
error_code = "agent_tool_invoke_failed"
description = "Agent tool invocation failed."
code = 500
def __init__(
self,
*,
error_code: str | None = None,
description: str | None = None,
status_code: int | None = None,
) -> None:
if error_code is not None:
self.error_code = error_code
if description is not None:
self.description = description
if status_code is not None:
self.code = status_code
super().__init__(self.description)
register_schema_models(inner_api_ns, AgentToolInvokeRequest)
register_response_schema_models(inner_api_ns, AgentToolInvokeResponse)
@inner_api_ns.route("/agent/tools/invoke")
class AgentToolInvokeApi(Resource):
"""Invoke one Agent tool through the API-owned core tool runtime path."""
@agent_inner_api_only
@inner_api_ns.doc("inner_agent_tool_invoke")
@inner_api_ns.expect(inner_api_ns.models[AgentToolInvokeRequest.__name__])
@inner_api_ns.response(200, "Tool invoked successfully", inner_api_ns.models[AgentToolInvokeResponse.__name__])
def post(self) -> dict[str, object]:
try:
payload = AgentToolInvokeRequest.model_validate(inner_api_ns.payload or {})
except ValidationError as exc:
raise AgentToolInvokeHttpError(
error_code="invalid_request",
description=str(exc),
status_code=400,
) from exc
try:
response = AgentToolInnerService().invoke(payload, session=db.session())
except AgentToolInnerServiceError as exc:
raise AgentToolInvokeHttpError(
error_code=exc.error_code,
description=exc.description,
status_code=exc.status_code,
) from exc
return response.model_dump(mode="json")
@@ -0,0 +1,243 @@
"""Inner API for Agent Soul-backed config assets.
These endpoints are called by the dify-agent server with the inner API key.
They resolve the requested Agent config version directly from Agent Soul JSON
and never expose signed download URLs or drive-owned metadata.
"""
from __future__ import annotations
import io
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ValidationError
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import plugin_inner_api_only
from services.agent_config_service import (
AgentConfigService,
AgentConfigServiceError,
AgentConfigVersionKind,
ConfigPushPayload,
)
class _ConfigTargetQuery(BaseModel):
tenant_id: str
user_id: str | None = None
config_version_id: str
config_version_kind: AgentConfigVersionKind
class _ConfigMutationRequest(BaseModel):
tenant_id: str
user_id: str
config_version_id: str
config_version_kind: AgentConfigVersionKind
class _ConfigPushRequest(_ConfigMutationRequest):
files: list[dict] = []
skills: list[dict] = []
env_text: str | None = None
note: str | None = None
def to_payload(self) -> ConfigPushPayload:
return ConfigPushPayload.model_validate(
{
"files": self.files,
"skills": self.skills,
"env_text": self.env_text,
"note": self.note,
}
)
class _ConfigEnvUpdateRequest(_ConfigMutationRequest):
env_text: str
class _ConfigNoteUpdateRequest(_ConfigMutationRequest):
note: str
def _target_query_from_request() -> _ConfigTargetQuery:
return _ConfigTargetQuery.model_validate(
{
"tenant_id": request.args.get("tenant_id"),
"user_id": request.args.get("user_id"),
"config_version_id": request.args.get("config_version_id"),
"config_version_kind": request.args.get("config_version_kind"),
}
)
def _error_response(exc: AgentConfigServiceError) -> tuple[dict[str, str], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
@inner_api_ns.route("/agent-config/<string:agent_id>/manifest")
class AgentConfigManifestApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_config_manifest")
def get(self, agent_id: str):
try:
query = _target_query_from_request()
return AgentConfigService().manifest(
tenant_id=query.tenant_id,
agent_id=agent_id,
user_id=query.user_id,
config_version_id=query.config_version_id,
config_version_kind=query.config_version_kind,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except AgentConfigServiceError as exc:
return _error_response(exc)
@inner_api_ns.route("/agent-config/<string:agent_id>/skills/<string:name>/pull")
class AgentConfigSkillPullApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_config_skill_pull")
def get(self, agent_id: str, name: str):
try:
query = _target_query_from_request()
result = AgentConfigService().pull_skill(
tenant_id=query.tenant_id,
agent_id=agent_id,
user_id=query.user_id,
config_version_id=query.config_version_id,
config_version_kind=query.config_version_kind,
name=name,
)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=True,
download_name=result.filename,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except AgentConfigServiceError as exc:
return _error_response(exc)
@inner_api_ns.route("/agent-config/<string:agent_id>/skills/<string:name>/inspect")
class AgentConfigSkillInspectApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_config_skill_inspect")
def get(self, agent_id: str, name: str):
try:
query = _target_query_from_request()
return AgentConfigService().inspect_skill(
tenant_id=query.tenant_id,
agent_id=agent_id,
user_id=query.user_id,
config_version_id=query.config_version_id,
config_version_kind=query.config_version_kind,
name=name,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except AgentConfigServiceError as exc:
return _error_response(exc)
@inner_api_ns.route("/agent-config/<string:agent_id>/files/<string:name>/pull")
class AgentConfigFilePullApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_config_file_pull")
def get(self, agent_id: str, name: str):
try:
query = _target_query_from_request()
result = AgentConfigService().pull_file(
tenant_id=query.tenant_id,
agent_id=agent_id,
user_id=query.user_id,
config_version_id=query.config_version_id,
config_version_kind=query.config_version_kind,
name=name,
)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=True,
download_name=result.filename,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except AgentConfigServiceError as exc:
return _error_response(exc)
@inner_api_ns.route("/agent-config/<string:agent_id>/push")
class AgentConfigPushApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_config_push")
def post(self, agent_id: str):
try:
body = _ConfigPushRequest.model_validate(request.get_json(silent=True) or {})
return AgentConfigService().push(
tenant_id=body.tenant_id,
agent_id=agent_id,
user_id=body.user_id,
config_version_id=body.config_version_id,
config_version_kind=body.config_version_kind,
payload=body.to_payload(),
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except AgentConfigServiceError as exc:
return _error_response(exc)
@inner_api_ns.route("/agent-config/<string:agent_id>/env")
class AgentConfigEnvApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_config_env")
def patch(self, agent_id: str):
try:
body = _ConfigEnvUpdateRequest.model_validate(request.get_json(silent=True) or {})
return AgentConfigService().update_env(
tenant_id=body.tenant_id,
agent_id=agent_id,
user_id=body.user_id,
config_version_id=body.config_version_id,
config_version_kind=body.config_version_kind,
env_text=body.env_text,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except AgentConfigServiceError as exc:
return _error_response(exc)
@inner_api_ns.route("/agent-config/<string:agent_id>/note")
class AgentConfigNoteApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_config_note")
def put(self, agent_id: str):
try:
body = _ConfigNoteUpdateRequest.model_validate(request.get_json(silent=True) or {})
return AgentConfigService().update_note(
tenant_id=body.tenant_id,
agent_id=agent_id,
user_id=body.user_id,
config_version_id=body.config_version_id,
config_version_kind=body.config_version_kind,
note=body.note,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except AgentConfigServiceError as exc:
return _error_response(exc)
+12
View File
@@ -95,3 +95,15 @@ def plugin_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return view(*args, **kwargs)
return decorated
def agent_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Temporary alias for agent-backend inner API callers.
Agent tool and knowledge calls currently share the same trusted
`dify-agent -> Dify API` transport credentials as the plugin inner bridge.
Keep the wrapper name agent-specific so the controller surface does not grow
more plugin-only semantics while auth settings stay shared.
"""
return plugin_inner_api_only(view)
+170 -32
View File
@@ -1,21 +1,28 @@
"""Agent App generator: orchestrate one conversation turn for an Agent App.
"""Agent App generator: orchestrate Agent App chat and finalize executions.
Mirrors the agent_chat generator (conversation + message + queue + streamed
response over the EasyUI chat pipeline), but the backing config comes from the
bound Agent Soul and the answer is produced by ``AgentAppRunner`` calling the
dify-agent backend rather than an in-process LLM/ReAct loop.
The primary mode mirrors the agent_chat generator (conversation + message +
queue + streamed response over the EasyUI chat pipeline), but the backing
config comes from the bound Agent Soul and the answer is produced by
``AgentAppRunner`` calling the dify-agent backend rather than an in-process
LLM/ReAct loop.
It also exposes a stateless build-finalize mode that reuses existing runtime
context from the bound debug conversation, triggers the Agent backend side
effect synchronously, and skips Dify-side chat/message persistence.
"""
from __future__ import annotations
import contextvars
import json
import logging
import threading
import uuid
from collections.abc import Generator, Mapping
from typing import Any
from collections.abc import Generator, Mapping, Sequence
from typing import Any, Literal
from flask import Flask, current_app
from pydantic import JsonValue
from sqlalchemy import and_, or_, select
from clients.agent_backend import AgentBackendRunEventAdapter
@@ -61,6 +68,13 @@ 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:
return query
return f"{query}\n{json.dumps(list(prompt_file_mappings), ensure_ascii=False)}"
class AgentAppGenerator(MessageBasedAppGenerator):
def generate(
self,
@@ -74,14 +88,12 @@ class AgentAppGenerator(MessageBasedAppGenerator):
if not streaming:
raise AgentAppGeneratorError("Agent App only supports streaming mode")
query = args.get("query")
if not isinstance(query, str) or not query.strip():
raise AgentAppGeneratorError("query is required")
query = query.replace("\x00", "")
query = self._require_query(args)
inputs = args["inputs"]
prompt_file_mappings = args.get("files") or []
# Resolve the bound roster Agent + its current Agent Soul snapshot.
agent, agent_config_id, agent_soul = self._resolve_agent(
agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent(
app_model,
invoke_from=invoke_from,
draft_type=args.get("draft_type"),
@@ -122,6 +134,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
),
query=query,
files=[],
prompt_file_mappings=prompt_file_mappings,
parent_message_id=(
args.get("parent_message_id")
if invoke_from not in {InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI}
@@ -137,6 +150,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=agent_config_id,
agent_config_version_kind=agent_config_version_kind,
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
)
@@ -176,6 +190,86 @@ class AgentAppGenerator(MessageBasedAppGenerator):
)
return AgentAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
def generate_stateless(
self,
*,
app_model: App,
user: Account | EndUser,
args: Mapping[str, Any],
invoke_from: InvokeFrom,
) -> Mapping[str, Any]:
"""Run one Agent App turn without persisting Dify conversation messages."""
query = self._require_query(args)
conversation_id = args.get("conversation_id")
if not isinstance(conversation_id, str) or not conversation_id:
raise AgentAppGeneratorError("conversation_id is required")
agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent(
app_model,
invoke_from=invoke_from,
draft_type=args.get("draft_type"),
user=user,
)
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
invoke_from=invoke_from,
snapshot_id=agent_config_id,
)
return self._run_stateless(
app_model=app_model,
user=user,
invoke_from=invoke_from,
query=query,
conversation_id=conversation_id,
agent=agent,
agent_config_id=agent_config_id,
agent_config_version_kind=agent_config_version_kind,
agent_soul=agent_soul,
runtime_session_snapshot_id=runtime_session_snapshot_id,
)
def _run_stateless(
self,
*,
app_model: App,
user: Account | EndUser,
invoke_from: InvokeFrom,
query: str,
conversation_id: str,
agent: Agent,
agent_config_id: str,
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"],
agent_soul: AgentSoulConfig,
runtime_session_snapshot_id: str | None,
) -> Mapping[str, Any]:
"""Run the Agent backend without creating or updating Dify chat records.
Build-chat finalization is an action against the Agent backend (for
example, ``dify-agent config push``). It may reuse the active build-chat
runtime snapshot for shell/config context, but the API side must not add
a synthetic user/assistant turn to the debug conversation.
"""
dify_context = DifyRunContext(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
user_id=user.id,
user_from=UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER,
invoke_from=invoke_from,
)
self._build_runner(dify_context).run_stateless(
dify_context=dify_context,
agent_id=agent.id,
agent_config_snapshot_id=agent_config_id,
agent_config_version_kind=agent_config_version_kind,
agent_soul=agent_soul,
conversation_id=conversation_id,
query=query,
idempotency_key=str(uuid.uuid4()),
session_scope_snapshot_id=runtime_session_snapshot_id,
)
return {"result": "success"}
def resume_after_form_submission(
self,
*,
@@ -192,15 +286,15 @@ class AgentAppGenerator(MessageBasedAppGenerator):
persisted to the conversation. Live streaming to a reconnected client is
out of scope here — the message is persisted and can be re-fetched.
"""
agent, agent_config_id, agent_soul = self._resolve_agent(
app_model,
invoke_from=invoke_from,
draft_type="draft",
user=user,
)
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=user
)
agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent(
app_model,
invoke_from=invoke_from,
draft_type=self._resume_draft_type(app_model=app_model, conversation=conversation, user=user),
user=user,
)
app_config = AgentAppConfigManager.get_app_config(
app_model=app_model,
@@ -245,6 +339,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=agent_config_id,
agent_config_version_kind=agent_config_version_kind,
)
conversation, message = self._init_generate_records(application_generate_entity, conversation)
@@ -285,6 +380,30 @@ class AgentAppGenerator(MessageBasedAppGenerator):
stream=False,
)
@staticmethod
def _resume_draft_type(*, app_model: App, conversation: Any, user: Account | EndUser) -> str | None:
if conversation.invoke_from != InvokeFrom.DEBUGGER:
return None
active_session = AgentAppRuntimeSessionStore().load_active_session_for_conversation(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
conversation_id=conversation.id,
)
snapshot_id = active_session.scope.agent_config_snapshot_id if active_session is not None else None
if snapshot_id and isinstance(user, Account):
draft = db.session.scalar(
select(AgentConfigDraft).where(
AgentConfigDraft.tenant_id == app_model.tenant_id,
AgentConfigDraft.id == snapshot_id,
)
)
if draft is not None:
if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD and draft.account_id == user.id:
return AgentConfigDraftType.DEBUG_BUILD.value
if draft.draft_type == AgentConfigDraftType.DRAFT and draft.account_id is None:
return AgentConfigDraftType.DRAFT.value
return AgentConfigDraftType.DRAFT.value
def _generate_worker(
self,
*,
@@ -329,6 +448,10 @@ class AgentAppGenerator(MessageBasedAppGenerator):
)
if handled:
return
query = _append_prompt_file_mappings(
query=query,
prompt_file_mappings=application_generate_entity.prompt_file_mappings,
)
dify_context = DifyRunContext(
tenant_id=app_config.tenant_id,
@@ -337,27 +460,18 @@ class AgentAppGenerator(MessageBasedAppGenerator):
user_from=user_from,
invoke_from=application_generate_entity.invoke_from,
)
credentials_provider, _ = build_dify_model_access(dify_context)
_, _, agent_soul = self._resolve_agent_by_id(
tenant_id=app_config.tenant_id,
agent_id=application_generate_entity.agent_id,
snapshot_id=application_generate_entity.agent_config_snapshot_id,
)
runner = AgentAppRunner(
request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider),
agent_backend_client=create_agent_backend_run_client(
base_url=dify_config.AGENT_BACKEND_BASE_URL,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
),
event_adapter=AgentBackendRunEventAdapter(),
session_store=AgentAppRuntimeSessionStore(),
)
runner = self._build_runner(dify_context)
runner.run(
dify_context=dify_context,
agent_id=application_generate_entity.agent_id,
agent_config_snapshot_id=application_generate_entity.agent_config_snapshot_id,
agent_config_version_kind=application_generate_entity.agent_config_version_kind,
agent_soul=agent_soul,
conversation_id=conversation.id,
query=query,
@@ -374,6 +488,27 @@ class AgentAppGenerator(MessageBasedAppGenerator):
finally:
db.session.close()
@staticmethod
def _require_query(args: Mapping[str, Any]) -> str:
query = args.get("query")
if not isinstance(query, str) or not query.strip():
raise AgentAppGeneratorError("query is required")
return query.replace("\x00", "")
@staticmethod
def _build_runner(dify_context: DifyRunContext) -> AgentAppRunner:
credentials_provider, _ = build_dify_model_access(dify_context)
return AgentAppRunner(
request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider),
agent_backend_client=create_agent_backend_run_client(
base_url=dify_config.AGENT_BACKEND_BASE_URL,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
),
event_adapter=AgentBackendRunEventAdapter(),
session_store=AgentAppRuntimeSessionStore(),
)
def _run_input_guards(
self,
*,
@@ -446,7 +581,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
invoke_from: InvokeFrom,
draft_type: Any,
user: Account | EndUser,
) -> tuple[Agent, str, AgentSoulConfig]:
) -> tuple[Agent, str, Literal["snapshot", "draft", "build_draft"], AgentSoulConfig]:
agent = db.session.scalar(
select(Agent)
.where(
@@ -474,13 +609,16 @@ class AgentAppGenerator(MessageBasedAppGenerator):
account_id=user.id if isinstance(user, Account) else None,
)
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
return agent, draft.id, agent_soul
config_version_kind: Literal["snapshot", "draft", "build_draft"] = (
"build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft"
)
return agent, draft.id, config_version_kind, agent_soul
_, snapshot, agent_soul = self._resolve_agent_by_id(
tenant_id=app_model.tenant_id,
agent_id=agent.id,
snapshot_id=agent.active_config_snapshot_id,
)
return agent, snapshot.id, agent_soul
return agent, snapshot.id, "snapshot", agent_soul
@staticmethod
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
+436 -34
View File
@@ -1,18 +1,23 @@
"""Agent App runner: drive one conversation turn through the dify-agent backend.
"""Agent App runner: drive Agent backend turns for both chat and finalize flows.
Unlike the legacy ``AgentChatAppRunner`` (which runs an in-process ReAct loop),
this runner delegates to the Agent backend: build the run request from the
Agent Soul + conversation, create the run, consume its event stream, and
republish the assistant answer as chat queue events so the existing
EasyUI chat task pipeline persists the message and streams SSE. The conversation
``session_snapshot`` is saved on success for multi-turn continuity (S3).
this runner delegates to the Agent backend and supports two execution modes.
- Normal chat turns build the run request from the Agent Soul + conversation,
consume backend stream events, republish the assistant answer through the
existing EasyUI chat task pipeline, and save the conversation
``session_snapshot`` on success for multi-turn continuity (S3).
- Stateless build-finalize turns reuse any prior conversation snapshot only to
construct the backend request, wait synchronously for backend completion, and
intentionally do not persist Dify-side chat records or runtime-session state.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from decimal import Decimal
from typing import Any, Literal
from dify_agent.layers.ask_human import AskHumanToolArgs
from dify_agent.protocol import DeferredToolResultsPayload
@@ -28,6 +33,7 @@ from clients.agent_backend import (
AgentBackendStreamInternalEvent,
extract_runtime_layer_specs,
)
from configs import dify_config
from core.app.apps.agent_app.runtime_request_builder import (
AgentAppRuntimeBuildContext,
AgentAppRuntimeRequest,
@@ -41,13 +47,16 @@ from core.app.apps.agent_app.session_store import (
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import DifyRunContext
from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent
from core.app.entities.queue_entities import QueueAgentThoughtEvent, QueueLLMChunkEvent, QueueMessageEndEvent
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form
from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
from extensions.ext_database import db
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage, UserPromptMessage
from models.agent_config_entities import AgentSoulConfig
from models.enums import CreatorUserRole
from models.model import MessageAgentThought
logger = logging.getLogger(__name__)
@@ -134,6 +143,283 @@ def publish_message_end(
)
class _AgentProcessRecorder:
"""Persist Agent v2 thinking/tool process events through the legacy thought model."""
def __init__(
self,
*,
dify_context: DifyRunContext,
message_id: str,
queue_manager: AppQueueManager,
) -> None:
self._dify_context = dify_context
self._message_id = message_id
self._queue_manager = queue_manager
self._next_position = 1
self._thinking_by_index: dict[int, str] = {}
self._tool_by_index: dict[int, str] = {}
self._tool_by_call_id: dict[str, str] = {}
self._open_tool_by_name: dict[str, set[str]] = {}
def handle_stream_event(self, event: AgentBackendStreamInternalEvent) -> None:
data = event.data
if not isinstance(data, dict):
return
event_kind = data.get("event_kind")
if event_kind == "part_delta":
self._handle_part_delta(data)
elif event_kind == "part_start":
self._handle_part(data)
elif event_kind in {"function_tool_call", "output_tool_call"}:
self._handle_tool_call_event(data)
elif event_kind in {"function_tool_result", "output_tool_result"}:
self._handle_tool_result_event(data)
def _handle_part_delta(self, data: dict[str, Any]) -> None:
delta = data.get("delta")
if not isinstance(delta, dict):
return
index = _event_index(data)
delta_kind = delta.get("part_delta_kind")
if delta_kind == "thinking":
content_delta = delta.get("content_delta")
if isinstance(content_delta, str) and content_delta:
self._append_thinking(index, content_delta)
return
if delta_kind == "tool_call":
self._record_tool_call_delta(index, delta)
def _handle_part(self, data: dict[str, Any]) -> None:
part = data.get("part")
if not isinstance(part, dict):
return
index = _event_index(data)
part_kind = part.get("part_kind")
if part_kind == "thinking":
content = part.get("content")
if isinstance(content, str) and content:
self._append_thinking(index, content)
return
if part_kind in {"tool-call", "builtin-tool-call"}:
self._record_tool_call_part(index, part)
return
if part_kind in {"tool-return", "builtin-tool-return"}:
self._record_tool_return_part(part)
def _handle_tool_call_event(self, data: dict[str, Any]) -> None:
part = data.get("part")
if isinstance(part, dict):
self._record_tool_call_part(_event_index(data), part)
def _handle_tool_result_event(self, data: dict[str, Any]) -> None:
part = data.get("part") or data.get("result")
if isinstance(part, dict):
self._record_tool_return_part(part)
return
content = data.get("content")
if content is not None:
self._record_tool_observation(
tool_call_id=_string_or_none(data.get("tool_call_id")),
tool_name=_string_or_none(data.get("tool_name")),
observation=content,
)
def _append_thinking(self, index: int, content_delta: str) -> None:
thought_id = self._thinking_by_index.get(index)
if thought_id is None:
thought_id = self._create_thought(thought=content_delta)
self._thinking_by_index[index] = thought_id
return
self._update_thought(thought_id, thought_delta=content_delta)
def _record_tool_call_delta(self, index: int, delta: dict[str, Any]) -> None:
tool_call_id = _string_or_none(delta.get("tool_call_id"))
tool_name = _string_or_none(delta.get("tool_name_delta"))
args_delta = delta.get("args_delta")
thought_id = self._lookup_tool_thought(index=index, tool_call_id=tool_call_id)
if thought_id is None:
thought_id = self._create_thought(tool=tool_name, tool_input=_json_or_text(args_delta))
self._remember_tool_thought(
index=index, tool_call_id=tool_call_id, tool_name=tool_name, thought_id=thought_id
)
return
self._update_thought(
thought_id,
tool=tool_name,
tool_input_delta=_json_or_text(args_delta),
)
def _record_tool_call_part(self, index: int, part: dict[str, Any]) -> None:
tool_call_id = _string_or_none(part.get("tool_call_id"))
tool_name = _string_or_none(part.get("tool_name"))
thought_id = self._lookup_tool_thought(index=index, tool_call_id=tool_call_id)
if thought_id is None:
thought_id = self._create_thought(tool=tool_name, tool_input=_json_or_text(part.get("args")))
self._remember_tool_thought(
index=index, tool_call_id=tool_call_id, tool_name=tool_name, thought_id=thought_id
)
return
self._update_thought(
thought_id,
tool=tool_name,
tool_input=_json_or_text(part.get("args")),
)
def _record_tool_return_part(self, part: dict[str, Any]) -> None:
tool_call_id = _string_or_none(part.get("tool_call_id"))
tool_name = _string_or_none(part.get("tool_name"))
content = part.get("content")
if content is None:
content = part
self._record_tool_observation(tool_call_id=tool_call_id, tool_name=tool_name, observation=content)
def _record_tool_observation(self, *, tool_call_id: str | None, tool_name: str | None, observation: Any) -> None:
thought_id = self._lookup_observation_thought(tool_call_id=tool_call_id, tool_name=tool_name)
if thought_id is None:
thought_id = self._create_thought(tool=tool_name)
else:
self._mark_tool_observed(thought_id)
self._update_thought(thought_id, observation=_json_or_text(observation))
def _lookup_tool_thought(self, *, index: int, tool_call_id: str | None) -> str | None:
if tool_call_id and tool_call_id in self._tool_by_call_id:
return self._tool_by_call_id[tool_call_id]
return self._tool_by_index.get(index)
def _remember_tool_thought(
self, *, index: int, tool_call_id: str | None, tool_name: str | None, thought_id: str
) -> None:
self._tool_by_index[index] = thought_id
if tool_call_id:
self._tool_by_call_id[tool_call_id] = thought_id
if tool_name:
self._open_tool_by_name.setdefault(tool_name, set()).add(thought_id)
def _lookup_observation_thought(self, *, tool_call_id: str | None, tool_name: str | None) -> str | None:
if tool_call_id:
return self._tool_by_call_id.get(tool_call_id)
if tool_name:
open_thought_ids = self._open_tool_by_name.get(tool_name, set())
if len(open_thought_ids) == 1:
return next(iter(open_thought_ids))
return None
def _mark_tool_observed(self, thought_id: str) -> None:
for open_thought_ids in self._open_tool_by_name.values():
open_thought_ids.discard(thought_id)
def _create_thought(
self, *, thought: str | None = None, tool: str | None = None, tool_input: str | None = None
) -> str:
row = MessageAgentThought(
message_id=self._message_id,
message_chain_id=None,
thought=thought,
tool=tool,
tool_labels_str=_tool_labels(tool),
tool_meta_str="{}",
tool_input=tool_input,
observation=None,
tool_process_data=None,
message=None,
message_token=0,
message_unit_price=Decimal(0),
message_price_unit=Decimal("0.001"),
message_files="",
answer="",
answer_token=0,
answer_unit_price=Decimal(0),
answer_price_unit=Decimal("0.001"),
tokens=0,
total_price=Decimal(0),
position=self._next_position,
currency="USD",
latency=0,
created_by_role=self._created_by_role(),
created_by=self._dify_context.user_id,
)
self._next_position += 1
db.session.add(row)
db.session.commit()
thought_id = str(row.id)
self._queue_manager.publish(
QueueAgentThoughtEvent(agent_thought_id=thought_id), PublishFrom.APPLICATION_MANAGER
)
return thought_id
def _update_thought(
self,
thought_id: str,
*,
thought_delta: str | None = None,
tool: str | None = None,
tool_input: str | None = None,
tool_input_delta: str | None = None,
observation: str | None = None,
) -> None:
row = db.session.get(MessageAgentThought, thought_id)
if row is None:
return
if thought_delta:
row.thought = f"{row.thought or ''}{thought_delta}"
if tool:
row.tool = tool
row.tool_labels_str = _tool_labels(tool)
if tool_input is not None:
row.tool_input = tool_input
if tool_input_delta:
row.tool_input = f"{row.tool_input or ''}{tool_input_delta}"
if observation is not None:
row.observation = observation
db.session.commit()
self._queue_manager.publish(
QueueAgentThoughtEvent(agent_thought_id=thought_id), PublishFrom.APPLICATION_MANAGER
)
def _created_by_role(self) -> CreatorUserRole:
if self._dify_context.invoke_from.runs_as_account():
return CreatorUserRole.ACCOUNT
return CreatorUserRole.END_USER
def _event_index(data: dict[str, Any]) -> int:
index = data.get("index")
return index if isinstance(index, int) else -1
def _string_or_none(value: Any) -> str | None:
return value if isinstance(value, str) and value else None
def _json_or_text(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
def _tool_labels(tool: str | None) -> str:
if not tool:
return "{}"
return json.dumps({tool: {"en_US": tool, "zh_Hans": tool}}, ensure_ascii=False)
class AgentAppRunner:
"""Runs one Agent App conversation turn against the Agent backend."""
@@ -156,6 +442,7 @@ class AgentAppRunner:
dify_context: DifyRunContext,
agent_id: str,
agent_config_snapshot_id: str,
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
agent_soul: AgentSoulConfig,
conversation_id: str,
query: str,
@@ -164,42 +451,34 @@ class AgentAppRunner:
queue_manager: AppQueueManager,
session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID,
) -> None:
if isinstance(session_scope_snapshot_id, _DefaultSessionScopeSnapshotId):
effective_session_scope_snapshot_id: str | None = agent_config_snapshot_id
else:
effective_session_scope_snapshot_id = session_scope_snapshot_id
scope = AgentAppSessionScope(
tenant_id=dify_context.tenant_id,
app_id=dify_context.app_id,
conversation_id=conversation_id,
scope = self._build_session_scope(
dify_context=dify_context,
agent_id=agent_id,
agent_config_snapshot_id=effective_session_scope_snapshot_id,
agent_config_snapshot_id=agent_config_snapshot_id,
conversation_id=conversation_id,
session_scope_snapshot_id=session_scope_snapshot_id,
)
# ENG-638: if a prior turn paused on ask_human and the form is now answered,
# resume by threading the human's reply into this run as deferred_tool_results.
stored = self._session_store.load_active_session(scope)
session_snapshot = stored.session_snapshot if stored is not None else None
deferred_tool_results = self._resolve_pending_ask_human(
stored=stored, dify_context=dify_context, message_id=message_id
)
runtime = self._request_builder.build(
AgentAppRuntimeBuildContext(
dify_context=dify_context,
agent_id=agent_id,
agent_config_snapshot_id=agent_config_snapshot_id,
agent_soul=agent_soul,
conversation_id=conversation_id,
user_query=query,
idempotency_key=message_id,
session_snapshot=session_snapshot,
deferred_tool_results=deferred_tool_results,
)
runtime = self._build_runtime(
dify_context=dify_context,
agent_id=agent_id,
agent_config_snapshot_id=agent_config_snapshot_id,
agent_config_version_kind=agent_config_version_kind,
agent_soul=agent_soul,
conversation_id=conversation_id,
query=query,
idempotency_key=message_id,
stored=stored,
message_id=message_id,
)
create_response = self._agent_backend_client.create_run(runtime.request)
terminal, streamed_answer = self._consume_stream(
create_response.run_id,
dify_context=dify_context,
message_id=message_id,
queue_manager=queue_manager,
model_name=model_name,
query=query,
@@ -241,6 +520,111 @@ class AgentAppRunner:
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
)
def run_stateless(
self,
*,
dify_context: DifyRunContext,
agent_id: str,
agent_config_snapshot_id: str,
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
agent_soul: AgentSoulConfig,
conversation_id: str,
query: str,
idempotency_key: str,
session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID,
) -> None:
"""Run the Agent backend without creating Dify chat message records.
This path is used by build-chat finalization: the API must trigger the
backend side effects in the existing conversation session, but it must
not persist a synthetic user/assistant turn, update API-side runtime
session rows, or set up HITL state that depends on one.
"""
scope = self._build_session_scope(
dify_context=dify_context,
agent_id=agent_id,
agent_config_snapshot_id=agent_config_snapshot_id,
conversation_id=conversation_id,
session_scope_snapshot_id=session_scope_snapshot_id,
)
runtime = self._build_runtime(
dify_context=dify_context,
agent_id=agent_id,
agent_config_snapshot_id=agent_config_snapshot_id,
agent_config_version_kind=agent_config_version_kind,
agent_soul=agent_soul,
conversation_id=conversation_id,
query=query,
idempotency_key=idempotency_key,
stored=self._session_store.load_active_session(scope),
message_id=None,
)
create_response = self._agent_backend_client.create_run(runtime.request)
status = self._agent_backend_client.wait_run(
create_response.run_id,
timeout_seconds=dify_config.APP_MAX_EXECUTION_TIME,
)
if status.status != "succeeded":
error = getattr(status, "error", None) or f"Agent backend run ended with status {status.status}."
raise AgentBackendError(str(error))
def _build_session_scope(
self,
*,
dify_context: DifyRunContext,
agent_id: str,
agent_config_snapshot_id: str,
conversation_id: str,
session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId,
) -> AgentAppSessionScope:
if isinstance(session_scope_snapshot_id, _DefaultSessionScopeSnapshotId):
effective_session_scope_snapshot_id: str | None = agent_config_snapshot_id
else:
effective_session_scope_snapshot_id = session_scope_snapshot_id
return AgentAppSessionScope(
tenant_id=dify_context.tenant_id,
app_id=dify_context.app_id,
conversation_id=conversation_id,
agent_id=agent_id,
agent_config_snapshot_id=effective_session_scope_snapshot_id,
)
def _build_runtime(
self,
*,
dify_context: DifyRunContext,
agent_id: str,
agent_config_snapshot_id: str,
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"],
agent_soul: AgentSoulConfig,
conversation_id: str,
query: str,
idempotency_key: str,
stored: StoredAgentAppSession | None,
message_id: str | None,
) -> AgentAppRuntimeRequest:
session_snapshot = stored.session_snapshot if stored is not None else None
deferred_tool_results = (
self._resolve_pending_ask_human(stored=stored, dify_context=dify_context, message_id=message_id)
if message_id is not None
else None
)
return self._request_builder.build(
AgentAppRuntimeBuildContext(
dify_context=dify_context,
agent_id=agent_id,
agent_config_snapshot_id=agent_config_snapshot_id,
agent_config_version_kind=agent_config_version_kind,
agent_soul=agent_soul,
conversation_id=conversation_id,
user_query=query,
idempotency_key=idempotency_key,
session_snapshot=session_snapshot,
deferred_tool_results=deferred_tool_results,
)
)
def _pause_for_ask_human(
self,
*,
@@ -334,12 +718,19 @@ class AgentAppRunner:
self,
run_id: str,
*,
dify_context: DifyRunContext,
message_id: str,
queue_manager: AppQueueManager,
model_name: str,
query: str | None,
):
terminal = None
streamed_answer_parts: list[str] = []
process_recorder = _AgentProcessRecorder(
dify_context=dify_context,
message_id=message_id,
queue_manager=queue_manager,
)
for public_event in self._agent_backend_client.stream_events(run_id):
if queue_manager.is_stopped():
self._cancel_run(run_id)
@@ -353,6 +744,17 @@ class AgentAppRunner:
AgentBackendInternalEventType.STREAM_EVENT,
):
if isinstance(internal_event, AgentBackendStreamInternalEvent):
try:
process_recorder.handle_stream_event(internal_event)
except Exception:
db.session.rollback()
logger.warning(
"Failed to persist Agent App process event: run_id=%s message_id=%s event_kind=%s",
run_id,
message_id,
internal_event.event_kind,
exc_info=True,
)
text_delta = self._extract_stream_text_delta(internal_event)
if text_delta:
streamed_answer_parts.append(text_delta)
@@ -12,7 +12,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Protocol, cast
from typing import Any, Literal, Protocol, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.execution_context import (
@@ -29,20 +29,22 @@ from clients.agent_backend import (
redact_for_agent_backend_log,
)
from configs import dify_config
from core.app.entities.app_invoke_entities import DifyRunContext
from core.workflow.nodes.agent_v2.plugin_tools_builder import (
WorkflowAgentPluginToolsBuilder,
WorkflowAgentPluginToolsBuildError,
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
from core.workflow.nodes.agent_v2.dify_tools_builder import (
WorkflowAgentDifyToolLayersBuilder,
WorkflowAgentDifyToolsBuilder,
WorkflowAgentDifyToolsBuildError,
WorkflowAgentToolLayers,
)
from core.workflow.nodes.agent_v2.runtime_request_builder import (
append_runtime_warnings,
build_ask_human_layer_config,
build_drive_aware_soul_mention_resolver,
build_drive_layer_config,
build_config_aware_soul_mention_resolver,
build_config_layer_config,
build_knowledge_layer_config,
build_shell_layer_config,
)
from models.agent_config_entities import AgentSoulConfig
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
from models.provider_ids import ModelProviderID
from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions
@@ -68,6 +70,7 @@ class AgentAppRuntimeBuildContext:
conversation_id: str
user_query: str
idempotency_key: str
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot"
session_snapshot: CompositorSessionSnapshot | None = None
# ENG-638: set when resuming a chat turn after a submitted ask_human form.
deferred_tool_results: DeferredToolResultsPayload | None = None
@@ -88,11 +91,11 @@ class AgentAppRuntimeRequestBuilder:
*,
credentials_provider: CredentialsProvider,
request_builder: AgentBackendRunRequestBuilder | None = None,
plugin_tools_builder: WorkflowAgentPluginToolsBuilder | None = None,
dify_tools_builder: WorkflowAgentDifyToolLayersBuilder | None = None,
) -> None:
self._credentials_provider = credentials_provider
self._request_builder = request_builder or AgentBackendRunRequestBuilder()
self._plugin_tools_builder = plugin_tools_builder or WorkflowAgentPluginToolsBuilder()
self._dify_tools_builder = dify_tools_builder or WorkflowAgentDifyToolsBuilder()
def build(self, context: AgentAppRuntimeBuildContext) -> AgentAppRuntimeRequest:
agent_soul = context.agent_soul
@@ -105,38 +108,33 @@ class AgentAppRuntimeRequestBuilder:
metadata = self._build_metadata(context)
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
tools_layer = self._plugin_tools_builder.build(
tool_layers = self._build_tool_layers(
tenant_id=context.dify_context.tenant_id,
app_id=context.dify_context.app_id,
user_id=context.dify_context.user_id,
tools=agent_soul.tools,
invoke_from=context.dify_context.invoke_from,
)
except WorkflowAgentPluginToolsBuildError as error:
except WorkflowAgentDifyToolsBuildError as error:
raise AgentAppRuntimeRequestBuildError(error.error_code, str(error)) from error
if tools_layer is not None or agent_soul.tools.cli_tools:
if tool_layers.plugin_tools is not None or tool_layers.core_tools is not None or agent_soul.tools.cli_tools:
metadata["agent_tools"] = {
"dify_tool_count": len(tools_layer.tools) if tools_layer is not None else 0,
"dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools]
if tools_layer is not None
else [],
"dify_tool_count": len(tool_layers.exposed_tool_names()),
"dify_tool_names": tool_layers.exposed_tool_names(),
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
drive_config = None
config_layer_config = None
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
drive_config, drive_warnings = build_drive_layer_config(
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent_id,
)
append_runtime_warnings(metadata, drive_warnings)
soul_prompt_resolver = build_drive_aware_soul_mention_resolver(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent_id,
config_version_id=context.agent_config_snapshot_id,
config_version_kind=context.agent_config_version_kind,
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
knowledge_config = build_knowledge_layer_config(agent_soul)
request = self._request_builder.build_for_agent_app(
@@ -158,6 +156,7 @@ class AgentAppRuntimeRequestBuilder:
conversation_id=context.conversation_id,
agent_id=context.agent_id,
agent_config_version_id=context.agent_config_snapshot_id,
agent_config_version_kind=context.agent_config_version_kind,
# Agent Files §1.3: real Dify access context + agent run mode.
user_from=cast(DifyExecutionContextUserFrom, context.dify_context.user_from.value),
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
@@ -168,9 +167,10 @@ class AgentAppRuntimeRequestBuilder:
agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
or None,
user_prompt=context.user_query,
tools=tools_layer,
tools=tool_layers.plugin_tools,
core_tools=tool_layers.core_tools,
knowledge=knowledge_config,
drive_config=drive_config,
config_layer_config=config_layer_config,
ask_human_config=build_ask_human_layer_config(agent_soul),
include_shell=dify_config.AGENT_SHELL_ENABLED,
shell_config=build_shell_layer_config(agent_soul),
@@ -183,6 +183,26 @@ class AgentAppRuntimeRequestBuilder:
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
return AgentAppRuntimeRequest(request=request, redacted_request=redacted, metadata=metadata)
def _build_tool_layers(
self,
*,
tenant_id: str,
app_id: str,
user_id: str | None,
tools: AgentSoulToolsConfig,
invoke_from: InvokeFrom,
) -> WorkflowAgentToolLayers:
# Production Agent App runs intentionally keep existing plugin configs
# on the direct `dify.plugin.tools` route. This builder emits plugin
# tools directly and non-plugin Dify tools through `dify.core.tools`.
return self._dify_tools_builder.build_layers(
tenant_id=tenant_id,
app_id=app_id,
user_id=user_id,
tools=tools,
invoke_from=invoke_from,
)
@staticmethod
def _build_metadata(context: AgentAppRuntimeBuildContext) -> dict[str, Any]:
return {
+15 -2
View File
@@ -1,8 +1,8 @@
from collections.abc import Mapping, Sequence
from enum import StrEnum
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationInfo, field_validator
from constants import UUID_NIL
from core.app.app_config.entities import EasyUIBasedAppConfig, WorkflowUIBasedAppConfig
@@ -220,11 +220,24 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity):
accepted-entity union. The answer is produced by the dify-agent backend
rather than an in-process LLM call; ``model_conf`` is synthesized from the
bound Agent Soul model so the chat task pipeline can persist usage.
``agent_config_version_kind`` selects which Agent config surface the
backend should read from: immutable snapshot, shared draft, or per-user
build draft.
``agent_runtime_session_snapshot_id`` carries the runtime session scope
used to resume or suspend within the same editable config surface.
``prompt_file_mappings`` preserves the raw request ``files`` array for the
Agent backend prompt. These references are appended to the backend prompt
text while the stored chat message keeps the user's original query.
"""
agent_id: str
agent_config_snapshot_id: str
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot"
agent_runtime_session_snapshot_id: str | None = None
prompt_file_mappings: Sequence[JsonValue] = Field(default_factory=list)
class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity):
+3 -5
View File
@@ -111,7 +111,7 @@ class ToolEngine:
tool_messages=binary_files, agent_message=message, invoke_from=invoke_from, user_id=user_id
)
plain_text = ToolEngine._convert_tool_response_to_str(message_list)
plain_text = ToolEngine.tool_response_to_str(message_list)
meta = invocation_meta_dict["meta"]
@@ -234,10 +234,8 @@ class ToolEngine:
yield meta
@staticmethod
def _convert_tool_response_to_str(tool_response: list[ToolInvokeMessage]) -> str:
"""
Handle tool response
"""
def tool_response_to_str(tool_response: list[ToolInvokeMessage]) -> str:
"""Convert tool invoke messages into the plain-text observation shown to the model/user."""
parts: list[str] = []
json_parts: list[str] = []
@@ -0,0 +1,500 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Literal, Protocol, cast
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolProviderType, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_plugin import (
DifyPluginCredentialValue,
DifyPluginToolConfig,
DifyPluginToolCredentialType,
DifyPluginToolParameter,
DifyPluginToolParameterForm,
DifyPluginToolsLayerConfig,
)
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.agent.entities import AgentToolEntity
from core.app.entities.app_invoke_entities import InvokeFrom
from core.tools.__base.tool import Tool
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.errors import ToolProviderCredentialValidationError, ToolProviderNotFoundError
from core.tools.tool_manager import ToolManager
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
from extensions.ext_database import db
from models.agent_config_entities import AgentSoulDifyToolConfig, AgentSoulToolsConfig
from models.provider_ids import ToolProviderID
from models.tools import WorkflowToolProvider
from services.tools.mcp_tools_manage_service import MCPToolManageService
class WorkflowAgentDifyToolsBuildError(ValueError):
"""Raised when Agent Soul tools cannot be prepared for Agent backend."""
def __init__(self, error_code: str, message: str) -> None:
self.error_code = error_code
super().__init__(message)
class AgentToolRuntimeProvider(Protocol):
def get_agent_tool_runtime(
self,
tenant_id: str,
app_id: str,
agent_tool: AgentToolEntity,
user_id: str | None = None,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: Any | None = None,
allow_file_parameters: bool = False,
use_default_for_missing_form_parameters: bool = False,
) -> Tool: ...
class ProviderToolsLister(Protocol):
def __call__(
self,
*,
tenant_id: str,
provider_type: ToolProviderType,
provider_id: str,
) -> list[str]: ...
class MCPProviderIDResolver(Protocol):
def __call__(self, *, tenant_id: str, provider_id: str) -> str: ...
@dataclass(frozen=True, slots=True)
class WorkflowAgentToolLayers:
plugin_tools: DifyPluginToolsLayerConfig | None = None
core_tools: DifyCoreToolsLayerConfig | None = None
def exposed_tool_names(self) -> list[str]:
names: list[str] = []
if self.plugin_tools is not None:
names.extend(tool.name or tool.tool_name for tool in self.plugin_tools.tools)
if self.core_tools is not None:
names.extend(tool.name or tool.tool_name for tool in self.core_tools.tools)
return names
class WorkflowAgentDifyToolLayersBuilder(Protocol):
def build_layers(
self,
*,
tenant_id: str,
app_id: str,
user_id: str | None,
tools: AgentSoulToolsConfig,
invoke_from: InvokeFrom,
) -> WorkflowAgentToolLayers: ...
def _list_provider_tool_names(
*,
tenant_id: str,
provider_type: ToolProviderType,
provider_id: str,
) -> list[str]:
"""Tool names a provider currently declares for provider-level Agent entries."""
match provider_type:
case ToolProviderType.PLUGIN:
plugin_provider = ToolManager.get_plugin_provider(provider_id, tenant_id)
return [tool.entity.identity.name for tool in plugin_provider.get_tools() or []]
case ToolProviderType.BUILT_IN:
builtin_provider = ToolManager.get_builtin_provider(provider_id, tenant_id)
return [tool.entity.identity.name for tool in builtin_provider.get_tools() or []]
case ToolProviderType.API:
api_provider, _ = ToolManager.get_api_provider_controller(tenant_id, provider_id)
return [tool.entity.identity.name for tool in api_provider.get_tools(tenant_id) or []]
case ToolProviderType.WORKFLOW:
db_provider = db.session.scalar(
select(WorkflowToolProvider)
.where(
WorkflowToolProvider.id == provider_id,
WorkflowToolProvider.tenant_id == tenant_id,
)
.limit(1)
)
if db_provider is None:
raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found")
workflow_provider = WorkflowToolProviderController.from_db(db_provider)
return [tool.entity.identity.name for tool in workflow_provider.get_tools(tenant_id) or []]
case ToolProviderType.MCP:
mcp_provider = ToolManager.get_mcp_provider_controller(tenant_id, provider_id)
return [tool.entity.identity.name for tool in mcp_provider.get_tools() or []]
case _:
raise ToolProviderNotFoundError(f"provider type {provider_type.value} not found")
def _resolve_mcp_provider_id(*, tenant_id: str, provider_id: str) -> str:
"""Normalize MCP provider ids to the runtime-facing server identifier."""
service = MCPToolManageService(session=cast(Session, db.session))
try:
return service.get_provider_entity(provider_id, tenant_id, by_server_id=True).provider_id
except ValueError:
try:
return service.get_provider_entity(provider_id, tenant_id, by_server_id=False).provider_id
except ValueError as exc:
raise ToolProviderNotFoundError(f"mcp provider {provider_id} not found") from exc
class WorkflowAgentDifyToolsBuilder:
"""Prepare Agent Soul Dify tools for Agent backend run-layer configs.
Plugin tools keep their existing direct daemon path. Core-routed tools
(`builtin`/`api`/`workflow`/`mcp`) are emitted as `dify.core.tools`.
"""
def __init__(
self,
*,
tool_runtime_provider: AgentToolRuntimeProvider | None = None,
provider_tools_lister: ProviderToolsLister | None = None,
mcp_provider_id_resolver: MCPProviderIDResolver | None = None,
) -> None:
self._tool_runtime_provider = tool_runtime_provider or ToolManager
self._provider_tools_lister = provider_tools_lister or _list_provider_tool_names
self._mcp_provider_id_resolver = mcp_provider_id_resolver or _resolve_mcp_provider_id
def build_layers(
self,
*,
tenant_id: str,
app_id: str,
user_id: str | None,
tools: AgentSoulToolsConfig,
invoke_from: InvokeFrom,
) -> WorkflowAgentToolLayers:
"""Resolve user-selected Dify tools into direct/core Agent backend DTOs.
`invoke_from` is the real runtime caller category (DEBUGGER for a
Composer test run, SERVICE_API / WEB_APP for a published run). It must
be threaded through to `ToolManager` so credential quotas, rate limits,
and audit tags match the actual call site.
"""
enabled_tools = [tool for tool in tools.dify_tools if tool.enabled]
if not enabled_tools:
return WorkflowAgentToolLayers()
prepared_plugin: list[DifyPluginToolConfig] = []
prepared_core: list[DifyCoreToolConfig] = []
seen_names: set[str] = set()
for tool_config in self._expand_provider_entries(tenant_id=tenant_id, enabled_tools=enabled_tools):
normalized_tool_config = self._normalized_tool_config(tenant_id=tenant_id, tool_config=tool_config)
destination = self._tool_layer_destination(normalized_tool_config)
exposed_name = self._exposed_tool_name(normalized_tool_config)
if exposed_name in seen_names:
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_name_duplicated",
f"Duplicate Dify Tool name {exposed_name!r}.",
)
seen_names.add(exposed_name)
agent_tool = self._to_agent_tool_entity(normalized_tool_config)
tool_runtime = self._fetch_tool_runtime(
tenant_id=tenant_id,
app_id=app_id,
user_id=user_id,
agent_tool=agent_tool,
invoke_from=invoke_from,
tool_config=normalized_tool_config,
)
if destination == "plugin":
prepared_plugin.append(
self._to_plugin_backend_tool_config(normalized_tool_config, tool_runtime, exposed_name)
)
else:
prepared_core.append(
self._to_core_backend_tool_config(normalized_tool_config, tool_runtime, exposed_name)
)
return WorkflowAgentToolLayers(
plugin_tools=DifyPluginToolsLayerConfig(tools=prepared_plugin) if prepared_plugin else None,
core_tools=DifyCoreToolsLayerConfig(tools=prepared_core) if prepared_core else None,
)
def _expand_provider_entries(
self,
*,
tenant_id: str,
enabled_tools: list[AgentSoulDifyToolConfig],
) -> list[AgentSoulDifyToolConfig]:
"""Expand provider-level entries (`tool_name` omitted = all tools)."""
explicit_by_provider: dict[tuple[ToolProviderType, str], set[str]] = {}
for tool_config in enabled_tools:
if tool_config.tool_name is not None:
explicit_by_provider.setdefault(self._provider_key(tool_config), set()).add(tool_config.tool_name)
expanded: list[AgentSoulDifyToolConfig] = []
for tool_config in enabled_tools:
if tool_config.tool_name is not None:
expanded.append(tool_config)
continue
provider_type = ToolProviderType.value_of(tool_config.provider_type)
provider_id = self._provider_id(tool_config)
try:
tool_names = self._provider_declared_tool_names(
tenant_id=tenant_id,
provider_type=provider_type,
provider_id=provider_id,
)
except ToolProviderNotFoundError as exc:
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_declaration_not_found",
f"Dify Tool provider {provider_id!r} declaration not found: {exc}",
) from exc
if not tool_names:
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_declaration_not_found",
f"Dify Tool provider {provider_id!r} declares no tools.",
)
already_explicit = explicit_by_provider.get(self._provider_key(tool_config), set())
for tool_name in tool_names:
if tool_name in already_explicit:
continue
expanded.append(tool_config.model_copy(update={"tool_name": tool_name, "runtime_parameters": {}}))
return expanded
def _provider_declared_tool_names(
self,
*,
tenant_id: str,
provider_type: ToolProviderType,
provider_id: str,
) -> list[str]:
return self._provider_tools_lister(
tenant_id=tenant_id,
provider_type=provider_type,
provider_id=provider_id,
)
def _normalized_tool_config(
self,
*,
tenant_id: str,
tool_config: AgentSoulDifyToolConfig,
) -> AgentSoulDifyToolConfig:
if tool_config.provider_type != ToolProviderType.MCP.value:
return tool_config
provider_id = self._mcp_provider_id_resolver(tenant_id=tenant_id, provider_id=self._provider_id(tool_config))
return tool_config.model_copy(update={"provider_id": provider_id, "plugin_id": None, "provider": None})
def _fetch_tool_runtime(
self,
*,
tenant_id: str,
app_id: str,
user_id: str | None,
agent_tool: AgentToolEntity,
invoke_from: InvokeFrom,
tool_config: AgentSoulDifyToolConfig,
) -> Tool:
"""Resolve the API-side `Tool` runtime and map fetch errors to stable codes."""
try:
return self._tool_runtime_provider.get_agent_tool_runtime(
tenant_id=tenant_id,
app_id=app_id,
agent_tool=agent_tool,
user_id=user_id,
invoke_from=invoke_from,
variable_pool=None,
allow_file_parameters=True,
use_default_for_missing_form_parameters=True,
)
except ToolProviderNotFoundError as exc:
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_declaration_not_found",
f"Dify Tool {tool_config.tool_name!r} declaration not found: {exc}",
) from exc
except ToolProviderCredentialValidationError as exc:
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_credential_invalid",
f"Dify Tool {tool_config.tool_name!r} credential validation failed: {exc}",
) from exc
except ValueError as exc:
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_config_invalid",
f"Dify Tool {tool_config.tool_name!r} runtime construction failed: {exc}",
) from exc
@staticmethod
def _to_agent_tool_entity(tool_config: AgentSoulDifyToolConfig) -> AgentToolEntity:
assert tool_config.tool_name is not None
return AgentToolEntity(
provider_type=ToolProviderType.value_of(tool_config.provider_type),
provider_id=WorkflowAgentDifyToolsBuilder._provider_id(tool_config),
tool_name=tool_config.tool_name,
tool_parameters=dict(tool_config.runtime_parameters),
credential_id=tool_config.credential_ref.id if tool_config.credential_ref else None,
)
@staticmethod
def _provider_id(tool_config: AgentSoulDifyToolConfig) -> str:
if tool_config.provider_id:
return tool_config.provider_id
assert tool_config.plugin_id is not None
assert tool_config.provider is not None
return f"{tool_config.plugin_id}/{tool_config.provider}"
@staticmethod
def _provider_key(tool_config: AgentSoulDifyToolConfig) -> tuple[ToolProviderType, str]:
return (
ToolProviderType.value_of(tool_config.provider_type),
WorkflowAgentDifyToolsBuilder._provider_id(tool_config),
)
@staticmethod
def _tool_layer_destination(tool_config: AgentSoulDifyToolConfig) -> Literal["plugin", "core"]:
provider_type = ToolProviderType.value_of(tool_config.provider_type)
if provider_type is ToolProviderType.PLUGIN:
return "plugin"
if provider_type in {
ToolProviderType.BUILT_IN,
ToolProviderType.API,
ToolProviderType.WORKFLOW,
ToolProviderType.MCP,
}:
return "core"
if provider_type is ToolProviderType.DATASET_RETRIEVAL:
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_provider_not_supported",
"dataset-retrieval remains on the knowledge path and is not supported in Agent tool layers.",
)
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_provider_not_supported",
f"Dify Tool provider type {provider_type.value!r} is not supported in Agent tool layers.",
)
@staticmethod
def _exposed_tool_name(tool_config: AgentSoulDifyToolConfig) -> str:
assert tool_config.tool_name is not None
return tool_config.tool_name
def _to_plugin_backend_tool_config(
self,
tool_config: AgentSoulDifyToolConfig,
tool_runtime: Tool,
exposed_name: str,
) -> DifyPluginToolConfig:
runtime = tool_runtime.runtime
if runtime is None:
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_config_invalid",
f"Dify Tool {tool_config.tool_name!r} has no runtime.",
)
provider_id = self._provider_id(tool_config)
plugin_id, provider = self._plugin_provider(tool_config, provider_id)
parameters = self._prepared_parameters(tool_runtime)
runtime_parameters = self._runtime_parameters(tool_runtime, parameters)
description = self._description(tool_config, tool_runtime)
return DifyPluginToolConfig(
plugin_id=plugin_id,
provider=provider,
tool_name=exposed_name,
credential_type=self._credential_type(tool_config, runtime.credentials),
name=exposed_name,
description=description,
credentials=self._normalize_credentials(runtime.credentials, tool_name=exposed_name),
runtime_parameters=runtime_parameters,
parameters=parameters,
parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(),
)
def _to_core_backend_tool_config(
self,
tool_config: AgentSoulDifyToolConfig,
tool_runtime: Tool,
exposed_name: str,
) -> DifyCoreToolConfig:
parameters = self._prepared_parameters(tool_runtime)
return DifyCoreToolConfig(
provider_type=cast(DifyCoreToolProviderType, tool_config.provider_type),
provider_id=self._provider_id(tool_config),
tool_name=tool_config.tool_name or exposed_name,
credential_id=tool_config.credential_ref.id if tool_config.credential_ref else None,
name=exposed_name,
description=self._description(tool_config, tool_runtime),
runtime_parameters=self._runtime_parameters(tool_runtime, parameters),
parameters=parameters,
parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(),
)
@staticmethod
def _plugin_provider(tool_config: AgentSoulDifyToolConfig, provider_id: str) -> tuple[str, str]:
if tool_config.plugin_id and tool_config.provider:
return tool_config.plugin_id, tool_config.provider
provider_id_entity = ToolProviderID(provider_id)
return provider_id_entity.plugin_id, provider_id_entity.provider_name
@staticmethod
def _credential_type(
tool_config: AgentSoulDifyToolConfig,
credentials: Mapping[str, Any],
) -> DifyPluginToolCredentialType:
if not credentials and tool_config.credential_type == "unauthorized":
return "unauthorized"
return tool_config.credential_type
@staticmethod
def _prepared_parameters(tool_runtime: Tool) -> list[DifyPluginToolParameter]:
return [
DifyPluginToolParameter.model_validate(parameter.model_dump(mode="json"))
for parameter in tool_runtime.get_merged_runtime_parameters()
]
@staticmethod
def _description(tool_config: AgentSoulDifyToolConfig, tool_runtime: Tool) -> str | None:
description = tool_config.description
if description is None and tool_runtime.entity.description is not None:
description = tool_runtime.entity.description.llm
return description
@staticmethod
def _runtime_parameters(
tool_runtime: Tool,
parameters: list[DifyPluginToolParameter],
) -> dict[str, Any]:
runtime = tool_runtime.runtime
runtime_parameters = dict(runtime.runtime_parameters if runtime is not None else {})
missing = [
parameter.name
for parameter in parameters
if parameter.form is not DifyPluginToolParameterForm.LLM
and parameter.required
and parameter.default is None
and parameter.name not in runtime_parameters
]
if missing:
names = ", ".join(sorted(missing))
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_runtime_parameter_missing",
f"Dify Tool {tool_runtime.entity.identity.name!r} is missing runtime parameters: {names}.",
)
return runtime_parameters
@staticmethod
def _normalize_credentials(
credentials: Mapping[str, Any],
*,
tool_name: str,
) -> dict[str, DifyPluginCredentialValue]:
normalized: dict[str, DifyPluginCredentialValue] = {}
for key, value in credentials.items():
if isinstance(value, str | int | float | bool) or value is None:
normalized[key] = value
continue
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_credential_shape_invalid",
(
f"Dify Plugin Tool {tool_name!r} credential {key!r} has a non-scalar value "
f"({type(value).__name__}); only str/int/float/bool/None are forwarded to the daemon."
),
)
return normalized
@@ -1,334 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Protocol
from dify_agent.layers.dify_plugin import (
DifyPluginCredentialValue,
DifyPluginToolConfig,
DifyPluginToolCredentialType,
DifyPluginToolParameter,
DifyPluginToolParameterForm,
DifyPluginToolsLayerConfig,
)
from core.agent.entities import AgentToolEntity
from core.app.entities.app_invoke_entities import InvokeFrom
from core.tools.__base.tool import Tool
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.errors import (
ToolProviderCredentialValidationError,
ToolProviderNotFoundError,
)
from core.tools.tool_manager import ToolManager
from models.agent_config_entities import AgentSoulDifyToolConfig, AgentSoulToolsConfig
from models.provider_ids import ToolProviderID
class WorkflowAgentPluginToolsBuildError(ValueError):
"""Raised when Agent Soul tools cannot be prepared for Agent backend."""
def __init__(self, error_code: str, message: str) -> None:
self.error_code = error_code
super().__init__(message)
class AgentToolRuntimeProvider(Protocol):
def get_agent_tool_runtime(
self,
tenant_id: str,
app_id: str,
agent_tool: AgentToolEntity,
user_id: str | None = None,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: Any | None = None,
allow_file_parameters: bool = False,
use_default_for_missing_form_parameters: bool = False,
) -> Tool: ...
class ProviderToolsLister(Protocol):
def __call__(self, *, tenant_id: str, provider_id: str) -> list[str]: ...
def _list_provider_tool_names(*, tenant_id: str, provider_id: str) -> list[str]:
"""Tool names a provider currently declares (provider-level config entries)."""
provider = ToolManager.get_builtin_provider(provider_id, tenant_id)
return [tool.entity.identity.name for tool in provider.get_tools() or []]
class WorkflowAgentPluginToolsBuilder:
"""Prepare Agent Soul Dify Plugin Tools for the public Agent backend DTO."""
def __init__(
self,
*,
tool_runtime_provider: AgentToolRuntimeProvider | None = None,
provider_tools_lister: ProviderToolsLister | None = None,
) -> None:
self._tool_runtime_provider = tool_runtime_provider or ToolManager
self._provider_tools_lister = provider_tools_lister or _list_provider_tool_names
def build(
self,
*,
tenant_id: str,
app_id: str,
user_id: str | None,
tools: AgentSoulToolsConfig,
invoke_from: InvokeFrom,
) -> DifyPluginToolsLayerConfig | None:
"""Resolve user-selected Dify Plugin Tools into the Agent backend DTO.
``invoke_from`` is the *real* runtime caller category (DEBUGGER for a
Composer test run, SERVICE_API / WEB_APP for a published run). It must
be threaded through to :class:`ToolManager` so credential quotas, rate
limits, and audit tags match the actual call site.
"""
enabled_tools = [tool for tool in tools.dify_tools if tool.enabled]
if not enabled_tools:
return None
prepared: list[DifyPluginToolConfig] = []
seen_names: set[str] = set()
for tool_config in self._expand_provider_entries(tenant_id=tenant_id, enabled_tools=enabled_tools):
agent_tool = self._to_agent_tool_entity(tool_config)
tool_runtime = self._fetch_tool_runtime(
tenant_id=tenant_id,
app_id=app_id,
user_id=user_id,
agent_tool=agent_tool,
invoke_from=invoke_from,
tool_config=tool_config,
)
exposed_name = self._exposed_tool_name(tool_config)
if exposed_name in seen_names:
raise WorkflowAgentPluginToolsBuildError(
"agent_tool_name_duplicated",
f"Duplicate Dify Plugin Tool name {exposed_name!r}.",
)
seen_names.add(exposed_name)
prepared.append(self._to_backend_tool_config(tool_config, tool_runtime, exposed_name))
return DifyPluginToolsLayerConfig(tools=prepared)
def _expand_provider_entries(
self,
*,
tenant_id: str,
enabled_tools: list[AgentSoulDifyToolConfig],
) -> list[AgentSoulDifyToolConfig]:
"""Expand provider-level entries (``tool_name`` omitted = all tools).
An explicit per-tool entry of the same provider wins over the expansion
(it may carry its own ``runtime_parameters``); expanded clones share the
provider entry's ``credential_ref`` and start with default parameters.
"""
explicit_by_provider: dict[str, set[str]] = {}
for tool_config in enabled_tools:
if tool_config.tool_name is not None:
explicit_by_provider.setdefault(self._provider_id(tool_config), set()).add(tool_config.tool_name)
expanded: list[AgentSoulDifyToolConfig] = []
for tool_config in enabled_tools:
if tool_config.tool_name is not None:
expanded.append(tool_config)
continue
provider_id = self._provider_id(tool_config)
try:
tool_names = self._provider_tools_lister(tenant_id=tenant_id, provider_id=provider_id)
except ToolProviderNotFoundError as exc:
raise WorkflowAgentPluginToolsBuildError(
"agent_tool_declaration_not_found",
f"Dify Plugin Tool provider {provider_id!r} declaration not found: {exc}",
) from exc
if not tool_names:
raise WorkflowAgentPluginToolsBuildError(
"agent_tool_declaration_not_found",
f"Dify Plugin Tool provider {provider_id!r} declares no tools.",
)
already_explicit = explicit_by_provider.get(provider_id, set())
for tool_name in tool_names:
if tool_name in already_explicit:
continue
expanded.append(tool_config.model_copy(update={"tool_name": tool_name, "runtime_parameters": {}}))
return expanded
def _fetch_tool_runtime(
self,
*,
tenant_id: str,
app_id: str,
user_id: str | None,
agent_tool: AgentToolEntity,
invoke_from: InvokeFrom,
tool_config: AgentSoulDifyToolConfig,
) -> Tool:
"""Resolve the API-side ``Tool`` runtime, mapping fetch errors to
Inspector-friendly error codes so callers can render distinct UX for
"tool definition gone" vs "credential failed".
"""
try:
return self._tool_runtime_provider.get_agent_tool_runtime(
tenant_id=tenant_id,
app_id=app_id,
agent_tool=agent_tool,
user_id=user_id,
invoke_from=invoke_from,
variable_pool=None,
allow_file_parameters=True,
use_default_for_missing_form_parameters=True,
)
except ToolProviderNotFoundError as exc:
raise WorkflowAgentPluginToolsBuildError(
"agent_tool_declaration_not_found",
f"Dify Plugin Tool {tool_config.tool_name!r} declaration not found: {exc}",
) from exc
except ToolProviderCredentialValidationError as exc:
raise WorkflowAgentPluginToolsBuildError(
"agent_tool_credential_invalid",
f"Dify Plugin Tool {tool_config.tool_name!r} credential validation failed: {exc}",
) from exc
except ValueError as exc:
# ToolManager raises bare ValueError when the agent tool's
# ``runtime`` / runtime parameters are missing. Surface it under a
# narrower error code than a generic "declaration not found" so
# frontend can render an actionable hint.
raise WorkflowAgentPluginToolsBuildError(
"agent_tool_config_invalid",
f"Dify Plugin Tool {tool_config.tool_name!r} runtime construction failed: {exc}",
) from exc
@staticmethod
def _to_agent_tool_entity(tool_config: AgentSoulDifyToolConfig) -> AgentToolEntity:
# Provider-level entries are expanded into per-tool clones before this point.
assert tool_config.tool_name is not None
return AgentToolEntity(
provider_type=ToolProviderType.value_of(tool_config.provider_type),
provider_id=WorkflowAgentPluginToolsBuilder._provider_id(tool_config),
tool_name=tool_config.tool_name,
tool_parameters=dict(tool_config.runtime_parameters),
credential_id=tool_config.credential_ref.id if tool_config.credential_ref else None,
)
@staticmethod
def _provider_id(tool_config: AgentSoulDifyToolConfig) -> str:
if tool_config.provider_id:
return tool_config.provider_id
assert tool_config.plugin_id is not None
assert tool_config.provider is not None
return f"{tool_config.plugin_id}/{tool_config.provider}"
@staticmethod
def _exposed_tool_name(tool_config: AgentSoulDifyToolConfig) -> str:
# Stage 3.1 decision: no user rename yet. Keep the model-visible tool
# name aligned with the plugin declaration identity. Provider-level
# entries are expanded into per-tool clones before this point.
assert tool_config.tool_name is not None
return tool_config.tool_name
def _to_backend_tool_config(
self,
tool_config: AgentSoulDifyToolConfig,
tool_runtime: Tool,
exposed_name: str,
) -> DifyPluginToolConfig:
runtime = tool_runtime.runtime
if runtime is None:
raise WorkflowAgentPluginToolsBuildError(
"agent_tool_config_invalid",
f"Dify Plugin Tool {tool_config.tool_name!r} has no runtime.",
)
provider_id = self._provider_id(tool_config)
plugin_id, provider = self._plugin_provider(tool_config, provider_id)
parameters = [
DifyPluginToolParameter.model_validate(parameter.model_dump(mode="json"))
for parameter in tool_runtime.get_merged_runtime_parameters()
]
runtime_parameters = self._runtime_parameters(tool_runtime, parameters)
description = tool_config.description
if description is None and tool_runtime.entity.description is not None:
description = tool_runtime.entity.description.llm
return DifyPluginToolConfig(
plugin_id=plugin_id,
provider=provider,
tool_name=exposed_name,
credential_type=self._credential_type(tool_config, runtime.credentials),
name=exposed_name,
description=description,
credentials=self._normalize_credentials(runtime.credentials, tool_name=exposed_name),
runtime_parameters=runtime_parameters,
parameters=parameters,
parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(),
)
@staticmethod
def _plugin_provider(tool_config: AgentSoulDifyToolConfig, provider_id: str) -> tuple[str, str]:
if tool_config.plugin_id and tool_config.provider:
return tool_config.plugin_id, tool_config.provider
provider_id_entity = ToolProviderID(provider_id)
return provider_id_entity.plugin_id, provider_id_entity.provider_name
@staticmethod
def _credential_type(
tool_config: AgentSoulDifyToolConfig,
credentials: Mapping[str, Any],
) -> DifyPluginToolCredentialType:
if not credentials and tool_config.credential_type == "unauthorized":
return "unauthorized"
return tool_config.credential_type
@staticmethod
def _runtime_parameters(
tool_runtime: Tool,
parameters: list[DifyPluginToolParameter],
) -> dict[str, Any]:
runtime = tool_runtime.runtime
runtime_parameters = dict(runtime.runtime_parameters if runtime is not None else {})
missing = [
parameter.name
for parameter in parameters
if parameter.form is not DifyPluginToolParameterForm.LLM
and parameter.required
and parameter.default is None
and parameter.name not in runtime_parameters
]
if missing:
names = ", ".join(sorted(missing))
raise WorkflowAgentPluginToolsBuildError(
"agent_tool_runtime_parameter_missing",
f"Dify Plugin Tool {tool_runtime.entity.identity.name!r} is missing runtime parameters: {names}.",
)
return runtime_parameters
@staticmethod
def _normalize_credentials(
credentials: Mapping[str, Any],
*,
tool_name: str,
) -> dict[str, DifyPluginCredentialValue]:
"""Forward only scalar credential values to the Agent backend.
``DifyPluginCredentialValue`` is ``str | int | float | bool | None``.
Refusing non-scalar values (lists, dicts, custom objects) is safer than
``str(value)`` — stringifying a nested OAuth token blob produces a
Python ``repr`` that the plugin daemon cannot use, and we'd rather
surface a clear ``agent_tool_credential_shape_invalid`` than send junk.
"""
normalized: dict[str, DifyPluginCredentialValue] = {}
for key, value in credentials.items():
if isinstance(value, str | int | float | bool) or value is None:
normalized[key] = value
continue
raise WorkflowAgentPluginToolsBuildError(
"agent_tool_credential_shape_invalid",
(
f"Dify Plugin Tool {tool_name!r} credential {key!r} has a non-scalar value "
f"({type(value).__name__}); only str/int/float/bool/None are forwarded to the daemon."
),
)
return normalized
@@ -8,9 +8,11 @@ from typing import Any, Literal, Protocol, assert_never, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.agent_stub.protocol import AgentStubFileMapping
from dify_agent.layers.ask_human import DifyAskHumanLayerConfig
from dify_agent.layers.drive import (
DifyDriveLayerConfig,
DifyDriveSkillConfig,
from dify_agent.layers.config import (
DifyConfigFileConfig,
DifyConfigLayerConfig,
DifyConfigSkillConfig,
DifyConfigVersionConfig,
)
from dify_agent.layers.execution_context import (
DifyExecutionContextInvokeFrom,
@@ -55,6 +57,7 @@ from models.agent_config_entities import (
AgentKnowledgeModelConfig,
AgentKnowledgeRetrievalConfig,
AgentSoulConfig,
AgentSoulToolsConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
@@ -76,10 +79,14 @@ from services.agent.prompt_mentions import (
parse_prompt_mentions,
workflow_previous_node_output_refs_from_selectors,
)
from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref
from .dify_tools_builder import (
WorkflowAgentDifyToolLayersBuilder,
WorkflowAgentDifyToolsBuilder,
WorkflowAgentDifyToolsBuildError,
WorkflowAgentToolLayers,
)
from .output_failure_orchestrator import retry_idempotency_key
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
from .runtime_feature_manifest import build_runtime_feature_manifest
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
@@ -97,6 +104,7 @@ _AGENT_STUB_FILE_TRANSFER_METHODS: Mapping[FileTransferMethod, AgentStubFileTran
FileTransferMethod.DATASOURCE_FILE: "datasource_file",
FileTransferMethod.REMOTE_URL: "remote_url",
}
_CANONICAL_DIFY_FILE_REFERENCE_PATTERN = r"^dify-file-ref:eyJyZWNvcmRfaWQiOi[A-Za-z0-9_-]+={0,2}$"
class WorkflowAgentRuntimeRequestBuildError(ValueError):
@@ -157,11 +165,11 @@ class WorkflowAgentRuntimeRequestBuilder:
*,
credentials_provider: CredentialsProvider,
request_builder: AgentBackendRunRequestBuilder | None = None,
plugin_tools_builder: WorkflowAgentPluginToolsBuilder | None = None,
dify_tools_builder: WorkflowAgentDifyToolLayersBuilder | None = None,
) -> None:
self._credentials_provider = credentials_provider
self._request_builder = request_builder or AgentBackendRunRequestBuilder()
self._plugin_tools_builder = plugin_tools_builder or WorkflowAgentPluginToolsBuilder()
self._dify_tools_builder = dify_tools_builder or WorkflowAgentDifyToolsBuilder()
def build(self, context: WorkflowAgentRuntimeBuildContext) -> WorkflowAgentRuntimeRequest:
agent_soul = AgentSoulConfig.model_validate(context.snapshot.config_snapshot_dict)
@@ -182,42 +190,33 @@ class WorkflowAgentRuntimeRequestBuilder:
user_prompt = workflow_context_prompt or self._WORKFLOW_USER_PROMPT_FALLBACK
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
tools_layer = self._plugin_tools_builder.build(
tool_layers = self._build_tool_layers(
tenant_id=context.dify_context.tenant_id,
app_id=context.dify_context.app_id,
user_id=context.dify_context.user_id,
tools=agent_soul.tools,
# Thread the *real* runtime invocation source through to
# ToolManager so credential quotas, rate limits, and audit
# trails match the actual call site (DEBUGGER for draft test
# run, SERVICE_API / WEB_APP for published run).
invoke_from=context.dify_context.invoke_from,
)
except WorkflowAgentPluginToolsBuildError as error:
except WorkflowAgentDifyToolsBuildError as error:
raise WorkflowAgentRuntimeRequestBuildError(error.error_code, str(error)) from error
if tools_layer is not None or agent_soul.tools.cli_tools:
if tool_layers.plugin_tools is not None or tool_layers.core_tools is not None or agent_soul.tools.cli_tools:
metadata["agent_tools"] = {
"dify_tool_count": len(tools_layer.tools) if tools_layer is not None else 0,
"dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools]
if tools_layer is not None
else [],
"dify_tool_count": len(tool_layers.exposed_tool_names()),
"dify_tool_names": tool_layers.exposed_tool_names(),
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
drive_config: DifyDriveLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
drive_config, drive_warnings = build_drive_layer_config(
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent.id,
)
append_runtime_warnings(metadata, drive_warnings)
soul_prompt_resolver = build_drive_aware_soul_mention_resolver(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent.id,
config_version_id=context.snapshot.id,
config_version_kind="snapshot",
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
knowledge_config = build_knowledge_layer_config(agent_soul)
@@ -251,6 +250,7 @@ class WorkflowAgentRuntimeRequestBuilder:
conversation_id=get_system_text(context.variable_pool, SystemVariableKey.CONVERSATION_ID),
agent_id=context.agent.id,
agent_config_version_id=context.snapshot.id,
agent_config_version_kind="snapshot",
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
),
@@ -258,9 +258,10 @@ class WorkflowAgentRuntimeRequestBuilder:
workflow_node_job_prompt=workflow_job_prompt,
user_prompt=user_prompt,
output=self._build_output_config(node_job.declared_outputs),
tools=tools_layer,
tools=tool_layers.plugin_tools,
core_tools=tool_layers.core_tools,
knowledge=knowledge_config,
drive_config=drive_config,
config_layer_config=config_layer_config,
ask_human_config=build_ask_human_layer_config(agent_soul),
include_shell=dify_config.AGENT_SHELL_ENABLED,
shell_config=build_shell_layer_config(agent_soul),
@@ -279,6 +280,26 @@ class WorkflowAgentRuntimeRequestBuilder:
metadata=metadata,
)
def _build_tool_layers(
self,
*,
tenant_id: str,
app_id: str,
user_id: str | None,
tools: AgentSoulToolsConfig,
invoke_from: InvokeFrom,
) -> WorkflowAgentToolLayers:
# Production workflow runs intentionally keep existing plugin configs on
# the direct `dify.plugin.tools` route. This builder emits plugin tools
# directly and non-plugin Dify tools through `dify.core.tools`.
return self._dify_tools_builder.build_layers(
tenant_id=tenant_id,
app_id=app_id,
user_id=user_id,
tools=tools,
invoke_from=invoke_from,
)
@staticmethod
def _agent_backend_agent_mode(invoke_from: InvokeFrom) -> Literal["workflow_run", "single_step"]:
if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.VALIDATION}:
@@ -494,7 +515,10 @@ class WorkflowAgentRuntimeRequestBuilder:
schema: dict[str, Any] = {"type": "object", "properties": properties}
if required:
schema["required"] = required
return AgentBackendOutputConfig(json_schema=schema)
return AgentBackendOutputConfig(
json_schema=schema,
description=WorkflowAgentRuntimeRequestBuilder._build_output_description(effective_outputs),
)
@staticmethod
def effective_declared_outputs(
@@ -551,48 +575,107 @@ class WorkflowAgentRuntimeRequestBuilder:
array_schema["items"]["description"] = array_item.description
return array_schema
case DeclaredOutputType.FILE:
return {
"oneOf": [
{
"type": "object",
"additionalProperties": False,
"properties": {
"transfer_method": {"const": FileTransferMethod.LOCAL_FILE.value},
"reference": {"type": "string"},
},
"required": ["transfer_method", "reference"],
},
{
"type": "object",
"additionalProperties": False,
"properties": {
"transfer_method": {"const": FileTransferMethod.TOOL_FILE.value},
"reference": {"type": "string"},
},
"required": ["transfer_method", "reference"],
},
{
"type": "object",
"additionalProperties": False,
"properties": {
"transfer_method": {"const": FileTransferMethod.DATASOURCE_FILE.value},
"reference": {"type": "string"},
},
"required": ["transfer_method", "reference"],
},
{
"type": "object",
"additionalProperties": False,
"properties": {
"transfer_method": {"const": FileTransferMethod.REMOTE_URL.value},
"url": {"type": "string"},
},
"required": ["transfer_method", "url"],
},
],
}
return WorkflowAgentRuntimeRequestBuilder._agent_stub_output_file_mapping_schema()
assert_never(output_type)
@staticmethod
def _agent_stub_output_file_mapping_schema() -> dict[str, Any]:
"""JSON Schema for Agent-produced output file mappings.
``AgentStubFileMapping.model_json_schema()`` cannot express the model's
``after`` validator: only ``remote_url`` may carry ``url``; every other
method must carry a canonical ``reference``. The structured-output model
needs that relationship in the schema, otherwise it may emit
``{"transfer_method": "local_file", "url": "..."}``, which passes the
broad generated schema but fails API-side output type checking.
For files produced inside an Agent run, the supported persisted shape is
narrower than every downloadable mapping: the sandbox must upload the
local artifact via ``dify-agent file upload <path>``, which returns a
``tool_file`` mapping. ``local_file`` and ``datasource_file`` are valid
for existing file references in workflow context, not for newly produced
Agent output files.
"""
return {
"title": "AgentStubFileMapping",
"description": (
"Agent output file mapping. Use `tool_file` with `reference` for files uploaded by "
"`dify-agent file upload <path>`; use `remote_url` only for files already reachable by URL."
),
"anyOf": [
{
"type": "object",
"additionalProperties": False,
"required": ["transfer_method", "reference"],
"properties": {
"transfer_method": {
"type": "string",
"enum": ["tool_file"],
},
"reference": {
"type": "string",
"minLength": 1,
"pattern": _CANONICAL_DIFY_FILE_REFERENCE_PATTERN,
"description": (
"Canonical Dify file reference returned by `dify-agent file upload <path>`. "
"Never use a local path, filename, URL, or synthesized dify-file-ref here."
),
},
},
},
{
"type": "object",
"additionalProperties": False,
"required": ["transfer_method", "url"],
"properties": {
"transfer_method": {
"type": "string",
"enum": ["remote_url"],
},
"url": {
"type": "string",
"minLength": 1,
"description": "Remote URL for a file that is already publicly reachable.",
},
},
},
],
}
@staticmethod
def _build_output_description(declared_outputs: Sequence[DeclaredOutputConfig]) -> str | None:
file_output_lines: list[str] = []
for output in declared_outputs:
if output.type == DeclaredOutputType.FILE:
file_output_lines.append(
f"- `{output.name}`: create the file in the sandbox, run `dify-agent file upload <path>`, "
f"and set `final_output.{output.name}` to the returned AgentStubFileMapping JSON object. "
"Do not call `final_output` before the upload command succeeds. Do not use the local path, "
"filename, URL, or a synthesized/base64-encoded value as the `reference`."
)
elif (
output.type == DeclaredOutputType.ARRAY
and output.array_item is not None
and output.array_item.type == DeclaredOutputType.FILE
):
file_output_lines.append(
f"- `{output.name}`: for every produced file, run `dify-agent file upload <path>` and set "
f"`final_output.{output.name}` to an array of the returned AgentStubFileMapping JSON objects. "
"Do not call `final_output` before all upload commands succeed. Do not use local paths, filenames, "
"URLs, or synthesized/base64-encoded values as `reference` values."
)
if not file_output_lines:
return None
return "\n".join(
[
"When filling file outputs, do not return a local filesystem path directly.",
"Upload each sandbox-local file through the Agent Stub CLI first. Copy the JSON printed by "
"`dify-agent file upload <path>` verbatim into the final output; never invent the `reference` value.",
*file_output_lines,
]
)
@staticmethod
def _apply_child_properties(schema: dict[str, Any], children: Sequence[DeclaredOutputChildConfig]) -> None:
if not children:
@@ -753,19 +836,12 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s
existing.extend(warnings)
def build_drive_aware_soul_mention_resolver(
agent_soul: AgentSoulConfig,
*,
tenant_id: str,
agent_id: str,
):
"""Resolve skill/file mentions against the agent drive and everything else via Agent Soul."""
def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
"""Resolve config skill/file mentions and delegate the rest to Agent Soul."""
base_resolver = build_soul_mention_resolver(agent_soul)
drive_service = AgentDriveService()
skill_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
skill_names_by_key = {skill["skill_md_key"]: skill["name"] for skill in skill_catalog}
drive_keys = {item["key"] for item in drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)}
skill_names = {item.name for item in agent_soul.config_skills}
file_names = {item.name for item in agent_soul.config_files}
def _resolve(mention: object) -> str | None:
if not hasattr(mention, "kind") or not hasattr(mention, "ref_id"):
@@ -774,88 +850,97 @@ def build_drive_aware_soul_mention_resolver(
ref_id = cast(str, mention.ref_id)
label = cast(str | None, getattr(mention, "label", None))
if kind == MentionKind.SKILL:
decoded_key = decode_drive_mention_ref(ref_id)
return skill_names_by_key.get(decoded_key) or label or decoded_key
return ref_id if ref_id in skill_names else label or ref_id
if kind == MentionKind.FILE:
decoded_key = decode_drive_mention_ref(ref_id)
if decoded_key in drive_keys:
return decoded_key.rsplit("/", 1)[-1]
return label or decoded_key
return ref_id if ref_id in file_names else label or ref_id
return base_resolver(cast(Any, mention))
return _resolve
def build_drive_layer_config(
def build_config_layer_config(
agent_soul: AgentSoulConfig,
*,
tenant_id: str,
agent_id: str | None,
) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]:
"""Derive drive runtime catalog + prompt-mentioned eager-pull keys from the drive."""
agent_id: str | None = None,
config_version_id: str | None = None,
config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
) -> tuple[DifyConfigLayerConfig | None, list[dict[str, str]]]:
"""Derive prompt-mentioned eager-pull names from Agent Soul."""
mentioned_drive_refs = [
decode_drive_mention_ref(mention.ref_id)
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt)
if mention.kind in {MentionKind.SKILL, MentionKind.FILE}
]
ordered_mentions = list(dict.fromkeys(ref for ref in mentioned_drive_refs if ref))
if not agent_id:
if not ordered_mentions:
return None, []
return None, [
{
"section": "agent_soul.prompt.system_prompt",
"code": "drive_ref_dangling",
"message": "drive mentions are configured but the run has no bound agent to address a drive by.",
}
]
ordered_mentions = list(
dict.fromkeys(
mention.ref_id
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt)
if mention.kind in {MentionKind.SKILL, MentionKind.FILE} and mention.ref_id
)
)
if (
not agent_soul.config_skills
and not agent_soul.config_files
and not agent_soul.config_note
and not ordered_mentions
):
return None, []
drive_service = AgentDriveService()
skills_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
manifest_items = drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)
manifest_by_key = {item["key"]: item for item in manifest_items}
skill_keys = {skill["skill_md_key"] for skill in skills_catalog}
skill_names = {skill.name for skill in agent_soul.config_skills}
file_names = {file_ref.name for file_ref in agent_soul.config_files}
warnings: list[dict[str, str]] = []
mentioned_skill_keys: list[str] = []
mentioned_file_keys: list[str] = []
for drive_key in ordered_mentions:
if drive_key in skill_keys:
mentioned_skill_keys.append(drive_key)
mentioned_skill_names: list[str] = []
mentioned_file_names: list[str] = []
for name in ordered_mentions:
if name in skill_names:
mentioned_skill_names.append(name)
continue
if drive_key in manifest_by_key:
mentioned_file_keys.append(drive_key)
if name in file_names:
mentioned_file_names.append(name)
continue
warnings.append(
{
"section": "agent_soul.prompt.system_prompt",
"code": "mention_target_missing",
"message": f"drive mention '{drive_key}' has no matching drive entry.",
"message": f"config mention '{name}' has no matching config asset.",
}
)
skills = [
DifyDriveSkillConfig(
path=skill["path"],
name=skill["name"],
description=skill["description"],
skill_md_key=skill["skill_md_key"],
archive_key=skill["archive_key"],
)
for skill in skills_catalog
]
return (
DifyDriveLayerConfig(
drive_ref=f"agent-{agent_id}",
skills=skills,
mentioned_skill_keys=mentioned_skill_keys,
mentioned_file_keys=mentioned_file_keys,
DifyConfigLayerConfig(
agent_id=agent_id,
config_version=DifyConfigVersionConfig(
id=config_version_id,
kind=config_version_kind,
writable=config_version_kind == "build_draft",
),
skills=[
DifyConfigSkillConfig(
name=skill.name,
description=skill.description,
size=skill.size,
mime_type=skill.mime_type,
)
for skill in agent_soul.config_skills
],
files=[
DifyConfigFileConfig(
name=file_ref.name,
size=file_ref.size,
mime_type=file_ref.mime_type,
)
for file_ref in agent_soul.config_files
],
env_keys=_agent_soul_config_env_keys(agent_soul),
note=agent_soul.config_note,
mentioned_skill_names=mentioned_skill_names,
mentioned_file_names=mentioned_file_names,
),
warnings,
)
def _agent_soul_config_env_keys(agent_soul: AgentSoulConfig) -> list[str]:
keys = [item.key or item.name or item.env_name or item.variable for item in agent_soul.env.variables]
return [key for key in keys if key]
def _cli_tool_enabled(item: object) -> bool:
"""A CLI tool is bootstrapped unless explicitly disabled (default is enabled)."""
data = _plain_mapping(item)
+3
View File
@@ -371,6 +371,9 @@ class WorkflowAgentComposerResponse(ResponseModel):
backing_app_id: str | None = None
hidden_app_backed: bool = False
chat_endpoint: str | None = None
debug_conversation_id: str | None = None
debug_conversation_has_messages: bool = False
debug_conversation_message_count: int = 0
workflow_id: str | None = None
node_id: str | None = None
+72 -8
View File
@@ -109,6 +109,7 @@ class OutputErrorStrategy(StrEnum):
# JSON-schema-friendly name pattern. Stage 4 §3.1 / §10.1.
_OUTPUT_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_CONFIG_SKILL_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
JsonPrimitive = str | int | float | bool | None
RuntimeParameterValue = JsonPrimitive | list[str] | list[int] | list[float] | list[bool]
@@ -167,6 +168,63 @@ class AgentSoulFilesConfig(BaseModel):
files: list[AgentFileRefConfig] = Field(default_factory=list)
def validate_config_name(name: str) -> str:
normalized = name.strip()
if not normalized:
raise ValueError("config asset name must not be blank")
if normalized in {".", ".."}:
raise ValueError("config asset name must not be '.' or '..'")
if "/" in normalized or "\\" in normalized:
raise ValueError("config asset name must be a single path segment")
if "\x00" in normalized or any(ord(ch) < 0x20 for ch in normalized):
raise ValueError("config asset name must not contain control characters")
return normalized
def validate_config_skill_name(name: str) -> str:
normalized = validate_config_name(name)
if _CONFIG_SKILL_NAME_PATTERN.fullmatch(normalized) is None:
raise ValueError(f"config skill name {normalized!r} must match {_CONFIG_SKILL_NAME_PATTERN.pattern}")
return normalized
class AgentConfigFileRefConfig(BaseModel):
"""Stable Agent Soul reference to one config file payload."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
file_kind: Literal["upload_file", "tool_file"]
file_id: str = Field(min_length=1, max_length=255)
size: int | None = None
hash: str | None = None
mime_type: str | None = None
@field_validator("name")
@classmethod
def _validate_name(cls, value: str) -> str:
return validate_config_name(value)
class AgentConfigSkillRefConfig(BaseModel):
"""Stable Agent Soul reference to one normalized skill archive."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: str = ""
file_kind: Literal["tool_file"] = "tool_file"
file_id: str = Field(min_length=1, max_length=255)
size: int | None = None
hash: str | None = None
mime_type: str | None = "application/zip"
@field_validator("name")
@classmethod
def _validate_name(cls, value: str) -> str:
return validate_config_skill_name(value)
class AgentPermissionConfig(BaseModel):
model_config = ConfigDict(extra="ignore")
@@ -527,12 +585,15 @@ class AgentSoulDifyToolCredentialRef(BaseModel):
class AgentSoulDifyToolConfig(BaseModel):
"""One Dify Plugin Tool configured on Agent Soul.
"""One Dify tool configured on Agent Soul.
The API backend prepares this persisted product shape into
``DifyPluginToolConfig`` before sending a run request to Agent backend.
``provider_id`` keeps compatibility with existing Agent tool config payloads;
new callers should send ``plugin_id`` + ``provider`` when available.
either ``DifyPluginToolConfig`` or ``DifyCoreToolConfig`` before sending a
run request to Agent backend. ``plugin`` providers keep the direct
``dify.plugin.tools`` transport; ``builtin`` / ``api`` / ``workflow`` /
``mcp`` providers are prepared for ``dify.core.tools``. ``provider_id``
keeps compatibility with existing Agent tool config payloads; new callers
should send ``plugin_id`` + ``provider`` when available.
"""
# ``extra="ignore"`` (not ``"allow"``) so historical Agent Soul payloads
@@ -542,10 +603,10 @@ class AgentSoulDifyToolConfig(BaseModel):
model_config = ConfigDict(extra="ignore")
enabled: bool = True
# Dify Plugin Tools live behind the ``PLUGIN`` provider type. ``BUILT_IN`` /
# ``WORKFLOW`` / ``API`` providers are not exposed to the Agent backend in
# this layer — keep the default narrow so a missing field surfaces as
# ``agent_tool_declaration_not_found`` against the correct provider table.
# ``plugin`` remains the default for legacy Agent Soul payloads. The runtime
# now also accepts ``builtin`` / ``api`` / ``workflow`` / ``mcp`` here and
# routes them through ``dify.core.tools``; keeping the default narrow still
# makes a missing field resolve against the plugin provider table.
provider_type: str = "plugin"
provider_id: str | None = Field(default=None, max_length=255)
plugin_id: str | None = Field(default=None, max_length=255)
@@ -682,6 +743,9 @@ class AgentSoulConfig(BaseModel):
knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig)
human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig)
env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig)
config_skills: list[AgentConfigSkillRefConfig] = Field(default_factory=list)
config_files: list[AgentConfigFileRefConfig] = Field(default_factory=list)
config_note: str = ""
files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
+695 -4
View File
@@ -465,6 +465,23 @@ Check if activation token is valid
| ---- | ----------- |
| 204 | Agent service API key deleted |
### [POST] /agent/{agent_id}/build-chat/finalize
Run a build-draft Agent App turn that asks the agent to push config updates
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 400 | Invalid request parameters | |
| 404 | Agent, build draft, or conversation not found | |
### [DELETE] /agent/{agent_id}/build-draft
#### Parameters
@@ -658,6 +675,237 @@ Stop a running Agent App chat message generation
| ---- | ----------- | ------ |
| 200 | Agent app composer validation result | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)<br> |
### [GET] /agent/{agent_id}/config/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config files | **application/json**: [AgentConfigFileListResponse](#agentconfigfilelistresponse)<br> |
### [POST] /agent/{agent_id}/config/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentConfigFileUploadPayload](#agentconfigfileuploadpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Uploaded config file | **application/json**: [AgentConfigFileUploadResponse](#agentconfigfileuploadresponse)<br> |
### [DELETE] /agent/{agent_id}/config/files/{name}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| name | path | Config file name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config file deleted | **application/json**: [AgentConfigDeleteResponse](#agentconfigdeleteresponse)<br> |
### [GET] /agent/{agent_id}/config/files/{name}/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| name | path | Config file name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config file download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /agent/{agent_id}/config/files/{name}/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| name | path | Config file name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview | **application/json**: [AgentConfigFilePreviewResponse](#agentconfigfilepreviewresponse)<br> |
### [GET] /agent/{agent_id}/config/manifest
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent config manifest | **application/json**: [AgentConfigManifestResponse](#agentconfigmanifestresponse)<br> |
### [GET] /agent/{agent_id}/config/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skills | **application/json**: [AgentConfigSkillListResponse](#agentconfigskilllistresponse)<br> |
### [POST] /agent/{agent_id}/config/skills/upload
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Uploaded config skill | **application/json**: [AgentConfigSkillUploadResponse](#agentconfigskilluploadresponse)<br> |
### [DELETE] /agent/{agent_id}/config/skills/{name}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| name | path | Config skill name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skill deleted | **application/json**: [AgentConfigDeleteResponse](#agentconfigdeleteresponse)<br> |
### [GET] /agent/{agent_id}/config/skills/{name}/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| name | path | Config skill name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skill download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /agent/{agent_id}/config/skills/{name}/files/content
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### [GET] /agent/{agent_id}/config/skills/{name}/files/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| name | path | Config skill name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| path | query | Normalized zip member path inside the skill package | Yes | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skill file download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /agent/{agent_id}/config/skills/{name}/files/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| name | path | Config skill name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| path | query | Normalized zip member path inside the skill package | Yes | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skill file preview | **application/json**: [AgentConfigSkillFilePreviewResponse](#agentconfigskillfilepreviewresponse)<br> |
### [GET] /agent/{agent_id}/config/skills/{name}/inspect
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| name | path | Config skill name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skill inspect view | **application/json**: [AgentConfigSkillInspectResponse](#agentconfigskillinspectresponse)<br> |
### [POST] /agent/{agent_id}/copy
#### Parameters
@@ -1633,6 +1881,250 @@ Run draft workflow for advanced chat application
| 400 | Invalid request parameters | |
| 403 | Permission denied | |
### [GET] /apps/{app_id}/agent/config/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config files | **application/json**: [AgentConfigFileListResponse](#agentconfigfilelistresponse)<br> |
### [POST] /apps/{app_id}/agent/config/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentConfigFileUploadPayload](#agentconfigfileuploadpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Uploaded config file | **application/json**: [AgentConfigFileUploadResponse](#agentconfigfileuploadresponse)<br> |
### [DELETE] /apps/{app_id}/agent/config/files/{name}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| name | path | Config file name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config file deleted | **application/json**: [AgentConfigDeleteResponse](#agentconfigdeleteresponse)<br> |
### [GET] /apps/{app_id}/agent/config/files/{name}/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| name | path | Config file name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config file download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /apps/{app_id}/agent/config/files/{name}/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| name | path | Config file name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview | **application/json**: [AgentConfigFilePreviewResponse](#agentconfigfilepreviewresponse)<br> |
### [GET] /apps/{app_id}/agent/config/manifest
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent config manifest | **application/json**: [AgentConfigManifestResponse](#agentconfigmanifestresponse)<br> |
### [GET] /apps/{app_id}/agent/config/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skills | **application/json**: [AgentConfigSkillListResponse](#agentconfigskilllistresponse)<br> |
### [POST] /apps/{app_id}/agent/config/skills/upload
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Uploaded config skill | **application/json**: [AgentConfigSkillUploadResponse](#agentconfigskilluploadresponse)<br> |
### [DELETE] /apps/{app_id}/agent/config/skills/{name}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| name | path | Config skill name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skill deleted | **application/json**: [AgentConfigDeleteResponse](#agentconfigdeleteresponse)<br> |
### [GET] /apps/{app_id}/agent/config/skills/{name}/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| name | path | Config skill name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skill download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /apps/{app_id}/agent/config/skills/{name}/files/content
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### [GET] /apps/{app_id}/agent/config/skills/{name}/files/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| name | path | Config skill name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| path | query | Normalized zip member path inside the skill package | Yes | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skill file download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /apps/{app_id}/agent/config/skills/{name}/files/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| name | path | Config skill name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| path | query | Normalized zip member path inside the skill package | Yes | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skill file preview | **application/json**: [AgentConfigSkillFilePreviewResponse](#agentconfigskillfilepreviewresponse)<br> |
### [GET] /apps/{app_id}/agent/config/skills/{name}/inspect
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| name | path | Config skill name | Yes | string |
| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string, <br>**Available values:** "debug_build", "draft" |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| version_id | query | Published snapshot ID for read-only version view | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Config skill inspect view | **application/json**: [AgentConfigSkillInspectResponse](#agentconfigskillinspectresponse)<br> |
### [GET] /apps/{app_id}/agent/drive/files
List agent drive entries (read-only inspector; one endpoint for both tabs)
@@ -12375,7 +12867,9 @@ Default namespace
| bound_agent_id | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| debug_conversation_has_messages | boolean | | No |
| debug_conversation_id | string | | No |
| debug_conversation_message_count | integer | | No |
| deleted_tools | [ [DeletedTool](#deletedtool) ] | | No |
| description | string | | No |
| enable_api | boolean | | Yes |
@@ -12703,6 +13197,19 @@ Risk marker for CLI tool bootstrap commands.
| result | string | | Yes |
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
#### AgentConfigDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| removed_names | [ string ] | | No |
| result | string | | Yes |
#### AgentConfigDownloadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| url | string | | Yes |
#### AgentConfigDraftSummaryResponse
| Name | Type | Description | Required |
@@ -12725,6 +13232,78 @@ Editable Agent Soul draft workspace type.
| ---- | ---- | ----------- | -------- |
| AgentConfigDraftType | string | Editable Agent Soul draft workspace type. | |
#### AgentConfigFileItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| file_id | string | | No |
| hash | string | | No |
| id | string | | Yes |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
#### AgentConfigFileItemsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| items | [ [AgentConfigFileItemResponse](#agentconfigfileitemresponse) ] | | No |
#### AgentConfigFileListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | Yes |
| config_version | [AgentConfigVersionResponse](#agentconfigversionresponse) | | Yes |
| items | [ [AgentConfigFileItemResponse](#agentconfigfileitemresponse) ] | | No |
#### AgentConfigFilePreviewResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| binary | boolean | | Yes |
| name | string | | Yes |
| size | integer | | No |
| text | string | | No |
| truncated | boolean | | Yes |
#### AgentConfigFileRefConfig
Stable Agent Soul reference to one config file payload.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| file_id | string | | Yes |
| file_kind | string, <br>**Available values:** "tool_file", "upload_file" | *Enum:* `"tool_file"`, `"upload_file"` | Yes |
| hash | string | | No |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
#### AgentConfigFileUploadPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| upload_file_id | string | UploadFile UUID from POST /console/api/files/upload | Yes |
#### AgentConfigFileUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| config_version | [AgentConfigVersionResponse](#agentconfigversionresponse) | | Yes |
| file | [AgentConfigFileItemResponse](#agentconfigfileitemresponse) | | Yes |
#### AgentConfigManifestResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | Yes |
| config_version | [AgentConfigVersionResponse](#agentconfigversionresponse) | | Yes |
| env_keys | [ string ] | | No |
| files | [AgentConfigFileItemsResponse](#agentconfigfileitemsresponse) | | No |
| note | string | | No |
| skills | [AgentConfigSkillItemsResponse](#agentconfigskillitemsresponse) | | No |
#### AgentConfigRevisionOperation
Audit operation recorded for Agent Soul version/revision changes.
@@ -12747,6 +13326,99 @@ Audit operation recorded for Agent Soul version/revision changes.
| summary | string | | No |
| version_note | string | | No |
#### AgentConfigSkillFilePreviewResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| binary | boolean | | Yes |
| path | string | | Yes |
| size | integer | | No |
| text | string | | No |
| truncated | boolean | | Yes |
#### AgentConfigSkillFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| downloadable | boolean | | Yes |
| name | string | | Yes |
| path | string | | Yes |
| previewable | boolean | | Yes |
| type | string, <br>**Available values:** "directory", "file" | *Enum:* `"directory"`, `"file"` | Yes |
#### AgentConfigSkillInspectResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| file_tree | [ object ] | | No |
| files | [ [AgentConfigSkillFileResponse](#agentconfigskillfileresponse) ] | | No |
| hash | string | | No |
| id | string | | Yes |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
| skill_md | [AgentConfigSkillMarkdownResponse](#agentconfigskillmarkdownresponse) | | Yes |
| source | string | | Yes |
| warnings | [ string ] | | No |
#### AgentConfigSkillItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| file_id | string | | No |
| hash | string | | No |
| id | string | | Yes |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
#### AgentConfigSkillItemsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| items | [ [AgentConfigSkillItemResponse](#agentconfigskillitemresponse) ] | | No |
#### AgentConfigSkillListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | Yes |
| config_version | [AgentConfigVersionResponse](#agentconfigversionresponse) | | Yes |
| items | [ [AgentConfigSkillItemResponse](#agentconfigskillitemresponse) ] | | No |
#### AgentConfigSkillMarkdownResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| binary | boolean | | Yes |
| path | string | | Yes |
| size | integer | | No |
| text | string | | Yes |
| truncated | boolean | | Yes |
#### AgentConfigSkillRefConfig
Stable Agent Soul reference to one normalized skill archive.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| file_id | string | | Yes |
| file_kind | string, <br>**Default:** tool_file | | No |
| hash | string | | No |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
#### AgentConfigSkillUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| config_version | [AgentConfigVersionResponse](#agentconfigversionresponse) | | Yes |
| skill | [AgentConfigSkillItemResponse](#agentconfigskillitemresponse) | | Yes |
#### AgentConfigSnapshotDetailResponse
| Name | Type | Description | Required |
@@ -12792,6 +13464,14 @@ Audit operation recorded for Agent Soul version/revision changes.
| version | integer | | Yes |
| version_note | string | | No |
#### AgentConfigVersionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| id | string | | Yes |
| kind | string, <br>**Available values:** "build_draft", "draft", "snapshot" | *Enum:* `"build_draft"`, `"draft"`, `"snapshot"` | Yes |
| writable | boolean | | Yes |
#### AgentDailyConversationStatisticResponse
| Name | Type | Description | Required |
@@ -12817,7 +13497,9 @@ Audit operation recorded for Agent Soul version/revision changes.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| debug_conversation_has_messages | boolean | | No |
| debug_conversation_id | string | | Yes |
| debug_conversation_message_count | integer | | No |
#### AgentDriveDeleteFileByAgentQuery
@@ -13588,6 +14270,9 @@ Visibility and lifecycle scope of an Agent record.
| ---- | ---- | ----------- | -------- |
| app_features | [AgentSoulAppFeaturesConfig](#agentsoulappfeaturesconfig) | | No |
| app_variables | [ [AppVariableConfig](#appvariableconfig) ] | | No |
| config_files | [ [AgentConfigFileRefConfig](#agentconfigfilerefconfig) ] | | No |
| config_note | string | | No |
| config_skills | [ [AgentConfigSkillRefConfig](#agentconfigskillrefconfig) ] | | No |
| env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No |
| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No |
| human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No |
@@ -13602,12 +14287,15 @@ Visibility and lifecycle scope of an Agent record.
#### AgentSoulDifyToolConfig
One Dify Plugin Tool configured on Agent Soul.
One Dify tool configured on Agent Soul.
The API backend prepares this persisted product shape into
``DifyPluginToolConfig`` before sending a run request to Agent backend.
``provider_id`` keeps compatibility with existing Agent tool config payloads;
new callers should send ``plugin_id`` + ``provider`` when available.
either ``DifyPluginToolConfig`` or ``DifyCoreToolConfig`` before sending a
run request to Agent backend. ``plugin`` providers keep the direct
``dify.plugin.tools`` transport; ``builtin`` / ``api`` / ``workflow`` /
``mcp`` providers are prepared for ``dify.core.tools``. ``provider_id``
keeps compatibility with existing Agent tool config payloads; new callers
should send ``plugin_id`` + ``provider`` when available.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -20790,6 +21478,9 @@ How a workflow node is bound to an Agent.
| backing_app_id | string | | No |
| binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No |
| chat_endpoint | string | | No |
| debug_conversation_has_messages | boolean | | No |
| debug_conversation_id | string | | No |
| debug_conversation_message_count | integer | | No |
| effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No |
| hidden_app_backed | boolean | | No |
| impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No |
+112 -8
View File
@@ -96,10 +96,14 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non
ComposerConfigValidator.validate_draft_save_payload(payload)
def _agent_soul_config_json(agent_soul: AgentSoulConfig | dict[str, Any]) -> dict[str, Any]:
return AgentSoulConfig.model_validate(agent_soul).model_dump(mode="json")
class AgentComposerService:
@classmethod
def load_workflow_composer(
cls, *, tenant_id: str, app_id: str, node_id: str, snapshot_id: str | None = None
cls, *, tenant_id: str, app_id: str, node_id: str, account_id: str | None = None, snapshot_id: str | None = None
) -> dict[str, Any]:
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)
@@ -115,7 +119,7 @@ class AgentComposerService:
agent=agent,
snapshot_id=snapshot_id,
)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version, account_id=account_id)
@classmethod
def _workflow_composer_version(
@@ -214,7 +218,7 @@ class AgentComposerService:
agent_id=agent.id if agent else None,
version_id=version_id,
)
state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version, account_id=account_id)
state["validation"] = cls.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
@@ -246,7 +250,7 @@ class AgentComposerService:
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version, account_id=account_id)
if binding.binding_type != WorkflowAgentBindingType.ROSTER_AGENT:
raise InvalidComposerConfigError("Workflow agent node must be bound to a roster agent.")
@@ -300,7 +304,9 @@ class AgentComposerService:
agent_id=inline_agent.id,
version_id=inline_agent.active_config_snapshot_id,
)
return cls._serialize_workflow_state(binding=binding, agent=inline_agent, version=version)
return cls._serialize_workflow_state(
binding=binding, agent=inline_agent, version=version, account_id=account_id
)
@classmethod
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
@@ -419,7 +425,11 @@ class AgentComposerService:
account_id_for_audit=account_id,
)
agent.updated_by = account_id
agent.active_config_is_published = False
agent.active_config_is_published = cls._agent_soul_matches_active_config(
tenant_id=tenant_id,
agent=agent,
agent_soul=payload.agent_soul,
)
db.session.commit()
state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id)
@@ -430,6 +440,54 @@ class AgentComposerService:
)
return state
@classmethod
def _agent_soul_matches_active_config(
cls,
*,
tenant_id: str,
agent: Agent,
agent_soul: AgentSoulConfig,
) -> bool:
if not agent.active_config_snapshot_id:
return False
active_version = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id,
version_id=agent.active_config_snapshot_id,
)
if not active_version:
return False
if agent.source == AgentSource.AGENT_APP and not cls._has_publish_visible_revision(
tenant_id=tenant_id,
agent_id=agent.id,
snapshot_id=agent.active_config_snapshot_id,
):
return False
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict)
@classmethod
def _has_publish_visible_revision(cls, *, tenant_id: str, agent_id: str, snapshot_id: str) -> bool:
revisions = db.session.scalars(
select(AgentConfigRevision.operation).where(
AgentConfigRevision.tenant_id == tenant_id,
AgentConfigRevision.agent_id == agent_id,
AgentConfigRevision.current_snapshot_id == snapshot_id,
)
).all()
return any(
operation
in {
AgentConfigRevisionOperation.PUBLISH_DRAFT,
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
AgentConfigRevisionOperation.RESTORE_VERSION,
}
for operation in revisions
)
@classmethod
def publish_agent_app_draft(
cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
@@ -557,16 +615,21 @@ class AgentComposerService:
)
if build_draft is None:
raise AgentVersionNotFoundError()
applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict)
normal_draft = cls._save_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
agent_soul=AgentSoulConfig.model_validate(build_draft.config_snapshot_dict),
agent_soul=applied_agent_soul,
account_id_for_audit=account_id,
base_snapshot_id=build_draft.base_snapshot_id,
)
agent.active_config_is_published = False
agent.active_config_is_published = cls._agent_soul_matches_active_config(
tenant_id=tenant_id,
agent=agent,
agent_soul=applied_agent_soul,
)
agent.updated_by = account_id
db.session.delete(build_draft)
db.session.commit()
@@ -1788,6 +1851,7 @@ class AgentComposerService:
binding: WorkflowAgentNodeBinding,
agent: Agent | None,
version: AgentConfigSnapshot | None,
account_id: str | None = None,
) -> dict[str, Any]:
locked = bool(agent and agent.scope == AgentScope.ROSTER)
save_options = [ComposerSaveStrategy.NODE_JOB_ONLY.value]
@@ -1801,6 +1865,19 @@ class AgentComposerService:
)
else:
save_options.append(ComposerSaveStrategy.SAVE_TO_ROSTER.value)
debug_conversation_id = cls._workflow_inline_debug_conversation_id(
tenant_id=binding.tenant_id,
binding=binding,
agent=agent,
account_id=account_id,
)
debug_conversation_message_count = (
AgentRosterService(db.session).count_agent_app_debug_conversation_messages(
conversation_id=debug_conversation_id
)
if debug_conversation_id
else 0
)
return {
"variant": ComposerVariant.WORKFLOW.value,
"agent": cls._serialize_agent(agent) if agent else None,
@@ -1835,10 +1912,37 @@ class AgentComposerService:
"backing_app_id": agent.backing_app_id if agent else None,
"hidden_app_backed": bool(agent and agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
"chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages" if agent else None,
"debug_conversation_id": debug_conversation_id,
"debug_conversation_has_messages": debug_conversation_message_count > 0,
"debug_conversation_message_count": debug_conversation_message_count,
"workflow_id": binding.workflow_id,
"node_id": binding.node_id,
}
@staticmethod
def _workflow_inline_debug_conversation_id(
*,
tenant_id: str,
binding: WorkflowAgentNodeBinding,
agent: Agent | None,
account_id: str | None,
) -> str | None:
if (
not account_id
or not agent
or binding.binding_type != WorkflowAgentBindingType.INLINE_AGENT
or agent.scope != AgentScope.WORKFLOW_ONLY
):
return None
from services.agent.roster_service import AgentRosterService
return AgentRosterService(db.session).get_or_create_agent_app_debug_conversation_id(
tenant_id=tenant_id,
agent_id=agent.id,
account_id=account_id,
)
@classmethod
def _serialize_agent(cls, agent: Agent) -> dict[str, Any]:
return {
@@ -0,0 +1,67 @@
"""Normalize uploaded config skills into one canonical ToolFile reference.
Config skills are Agent Soul-backed assets, not drive rows. This service keeps
the existing skill package validation rules, enforces the requested stable name,
stores the normalized archive as one ToolFile, and returns the persisted Soul
reference metadata used by ``AgentConfigService``.
"""
from __future__ import annotations
from core.tools.tool_file_manager import ToolFileManager
from models.agent_config_entities import AgentConfigSkillRefConfig, validate_config_skill_name
from services.agent.skill_package_service import NormalizedSkillPackage, SkillPackageError, SkillPackageService
class ConfigSkillNormalizeService:
"""Validate, normalize, and persist one config skill archive."""
def __init__(
self,
*,
package_service: SkillPackageService | None = None,
tool_file_manager: ToolFileManager | None = None,
) -> None:
self._package = package_service or SkillPackageService()
self._tool_files = tool_file_manager or ToolFileManager()
def normalize(
self,
*,
content: bytes,
filename: str,
requested_name: str | None,
tenant_id: str,
user_id: str,
) -> tuple[AgentConfigSkillRefConfig, NormalizedSkillPackage]:
package = self._package.validate_and_normalize(content=content, filename=filename)
normalized_name = validate_config_skill_name(requested_name or package.manifest.name)
if package.manifest.name != normalized_name:
raise SkillPackageError(
"skill_name_mismatch",
"skill package name must match the requested config skill name",
status_code=400,
)
tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=package.archive_bytes,
mimetype="application/zip",
filename=f"{normalized_name}.zip",
)
return (
AgentConfigSkillRefConfig(
name=normalized_name,
description=package.manifest.description,
file_id=tool_file.id,
size=tool_file.size,
hash=package.manifest.hash,
mime_type=tool_file.mimetype,
),
package,
)
__all__ = ["ConfigSkillNormalizeService"]
+30 -2
View File
@@ -33,6 +33,8 @@ from enum import StrEnum
from models.agent_config_entities import (
AgentHumanContactConfig,
AgentSoulConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
WorkflowPreviousNodeOutputRef,
)
@@ -50,6 +52,8 @@ class MentionKind(StrEnum):
MENTION_PATTERN = re.compile(
r"(?:\[§(?P<reversed_output_id>[^:§]+?):(?P<reversed_output_label>[^:§]*?):(?P<reversed_output_kind>output)§\])"
r"|"
r"(?:\[§(?P<bracket_kind>skill|file|tool|cli_tool|knowledge|human|node_output|output):"
r"(?P<bracket_id>[^:§]+?)(?::(?P<bracket_label>[^§]*?))?§\])"
r"|(?:§(?P<legacy_kind>output):(?P<legacy_id>[^:§]+?)(?::(?P<legacy_label>[^§]*?))?§)"
@@ -111,6 +115,8 @@ MentionResolver = Callable[[PromptMention], str | None]
def _mention_groups(match: re.Match[str]) -> tuple[str, str, str | None]:
if match.group("reversed_output_kind"):
return MentionKind.OUTPUT.value, match.group("reversed_output_id"), match.group("reversed_output_label")
kind = match.group("bracket_kind") or match.group("legacy_kind")
ref_id = match.group("bracket_id") or match.group("legacy_id")
label = match.group("bracket_label") or match.group("legacy_label")
@@ -162,7 +168,7 @@ def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
resolved = resolver(mention)
if resolved is None or not resolved.strip():
return fallback
return resolved[:MAX_MENTION_LABEL_LENGTH]
return resolved
return scrub_mention_markers(MENTION_PATTERN.sub(_replace, prompt))
@@ -298,7 +304,7 @@ def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionR
case MentionKind.OUTPUT:
for output in node_job.declared_outputs:
if output.name == mention.ref_id:
return f"{output.name} ({output.type.value})"
return _format_output_mention(output)
case MentionKind.HUMAN:
return _resolve_human_contact(node_job.human_contacts, mention.ref_id)
case _:
@@ -308,6 +314,28 @@ def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionR
return _resolve
def _format_output_mention(output: DeclaredOutputConfig) -> str:
if output.type == DeclaredOutputType.FILE:
return (
f"{output.name} (file output; create the file locally, run "
f"`dify-agent file upload <path>`, then copy the returned AgentStubFileMapping JSON "
f"as final_output.{output.name}; do not call final_output before upload succeeds, and do not use "
"the local path, filename, URL, or a synthesized dify-file-ref as the reference)"
)
if (
output.type == DeclaredOutputType.ARRAY
and output.array_item
and output.array_item.type == DeclaredOutputType.FILE
):
return (
f"{output.name} (array[file] output; upload each produced file with "
f"`dify-agent file upload <path>`, then copy the returned AgentStubFileMapping JSON objects "
f"as final_output.{output.name}; do not call final_output before all uploads succeed, and do not use "
"local paths, filenames, URLs, or synthesized dify-file-ref values as references)"
)
return f"{output.name} ({output.type.value})"
def _resolve_human_contact(contacts: list[AgentHumanContactConfig], ref_id: str) -> str | None:
for contact in contacts:
if ref_id in (contact.id, contact.contact_id, contact.human_id):
+34 -4
View File
@@ -24,7 +24,7 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import AppStatus, ConversationFromSource, ConversationStatus
from models.model import App, AppMode, AppModelConfig, Conversation, IconType
from models.model import App, AppMode, AppModelConfig, Conversation, IconType, Message
from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
@@ -571,6 +571,35 @@ class AgentRosterService:
self._session.commit()
return conversation_id
def load_agent_app_debug_conversation_id(self, *, tenant_id: str, agent_id: str, account_id: str) -> str | None:
"""Return the current editor's existing debug conversation without creating or repairing rows."""
return self._session.scalar(
select(Conversation.id)
.join(AgentDebugConversation, AgentDebugConversation.conversation_id == Conversation.id)
.where(
AgentDebugConversation.tenant_id == tenant_id,
AgentDebugConversation.agent_id == agent_id,
AgentDebugConversation.account_id == account_id,
AgentDebugConversation.app_id == Conversation.app_id,
Conversation.from_source == ConversationFromSource.CONSOLE,
Conversation.from_account_id == account_id,
Conversation.is_deleted.is_(False),
)
)
def count_agent_app_debug_conversation_messages(self, *, conversation_id: str) -> int:
"""Return the number of visible messages in an Agent App debug conversation."""
return (
self._session.scalar(
select(func.count(Message.id)).where(
Message.conversation_id == conversation_id,
)
)
or 0
)
def refresh_agent_app_debug_conversation_id(
self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True
) -> str:
@@ -992,9 +1021,10 @@ class AgentRosterService:
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
"""Return each Agent's stored normal-draft publish state.
The flag is maintained by write paths: normal shared draft writes mark it
dirty, while publish/version creation paths mark it clean. User-scoped
debug drafts intentionally do not affect this state.
The flag is maintained by write paths against the normal shared draft:
saves compare the draft content with the active snapshot, while publish
and version creation paths mark the new active snapshot clean.
User-scoped debug drafts intentionally do not affect this state.
"""
agents = [agent for agent in agents if agent.id]
if not agents:
+11 -28
View File
@@ -12,7 +12,6 @@ from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationE
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentDriveFile,
AgentScope,
AgentStatus,
WorkflowAgentBindingType,
@@ -187,38 +186,22 @@ class WorkflowAgentPublishService:
agent_soul: AgentSoulConfig,
) -> None:
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
from services.agent_drive_service import decode_drive_mention_ref
wanted_keys: dict[str, tuple[str, str]] = {}
del session
configured_skill_names = {item.name for item in agent_soul.config_skills}
configured_file_names = {item.name for item in agent_soul.config_files}
missing_refs: list[str] = []
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
continue
drive_key = decode_drive_mention_ref(mention.ref_id)
if not drive_key:
continue
code = "skill_ref_dangling" if mention.kind == MentionKind.SKILL else "file_ref_dangling"
wanted_keys[drive_key] = (code, mention.label or drive_key)
if not wanted_keys or not binding.agent_id:
return
existing_keys = set(
session.scalars(
select(AgentDriveFile.key).where(
AgentDriveFile.tenant_id == binding.tenant_id,
AgentDriveFile.agent_id == binding.agent_id,
AgentDriveFile.key.in_(sorted(wanted_keys)),
)
).all()
)
messages: list[str] = []
for key, (code, display) in wanted_keys.items():
if key in existing_keys:
continue
kind = "skill" if code == "skill_ref_dangling" else "file"
messages.append(f"{code}: {kind} '{display}' has no drive entry for key '{key}'.")
if messages:
ref_name = mention.ref_id
if mention.kind == MentionKind.SKILL and ref_name not in configured_skill_names:
missing_refs.append(f"skill_ref_dangling: skill '{mention.label or ref_name}' is not configured.")
if mention.kind == MentionKind.FILE and ref_name not in configured_file_names:
missing_refs.append(f"file_ref_dangling: file '{mention.label or ref_name}' is not configured.")
if missing_refs:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has invalid Agent Soul drive refs: {'; '.join(messages)}"
f"Workflow Agent node {binding.node_id} has invalid Agent Soul config refs: {'; '.join(missing_refs)}"
)
@classmethod
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
"""Service layer for Agent-backend core tool invocations.
The `dify.core.tools` layer executes inside the API service boundary so Dify
Agent can expose API-owned tool providers (`plugin`, `builtin`, `api`,
`workflow`, and `mcp`) without learning their storage, credential, or runtime
internals. Production workflow and agent-app request builders still keep
existing plugin tool configs on the direct `dify.plugin.tools` route by
default; this service is the lower-level API execution path used when a caller
explicitly submits a `dify.core.tools` declaration. The service validates
tenant/app ownership, reuses `ToolManager.get_agent_tool_runtime(...)` to build
the runtime with `ToolInvokeFrom.AGENT`, invokes through
`ToolEngine.generic_invoke(...)`, and formats observations through the shared
public `ToolEngine.tool_response_to_str` helper so Agent and workflow paths
stay consistent.
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from core.agent.entities import AgentToolEntity
from core.app.entities.app_invoke_entities import InvokeFrom
from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.errors import (
ToolInvokeError,
ToolNotFoundError,
ToolNotSupportedError,
ToolParameterValidationError,
ToolProviderCredentialValidationError,
ToolProviderNotFoundError,
)
from core.tools.tool_engine import ToolEngine
from core.tools.tool_manager import ToolManager
from core.tools.utils.message_transformer import ToolFileMessageTransformer
from models.model import App
from services.entities.agent_tool_inner import AgentToolInvokeRequest, AgentToolInvokeResponse
from services.errors.agent_tool_inner import AgentToolInnerServiceError
class AgentToolInnerService:
"""Invoke one API-owned Agent tool declaration, including explicit plugin-via-core calls."""
def invoke(self, request: AgentToolInvokeRequest, *, session: Session) -> AgentToolInvokeResponse:
app = session.get(App, request.caller.app_id)
if app is None:
raise AgentToolInnerServiceError(
error_code="app_not_found",
description="App not found.",
status_code=404,
)
if app.tenant_id != request.caller.tenant_id:
raise AgentToolInnerServiceError(
error_code="app_tenant_mismatch",
description="App does not belong to the caller tenant.",
status_code=403,
)
agent_tool = AgentToolEntity(
provider_type=ToolProviderType.value_of(request.tool.provider_type),
provider_id=request.tool.provider_id,
tool_name=request.tool.tool_name,
tool_parameters=dict(request.tool.runtime_parameters),
credential_id=request.tool.credential_id,
)
try:
tool_runtime = ToolManager.get_agent_tool_runtime(
tenant_id=request.caller.tenant_id,
app_id=request.caller.app_id,
agent_tool=agent_tool,
user_id=request.caller.user_id,
invoke_from=InvokeFrom.value_of(request.caller.invoke_from),
variable_pool=None,
allow_file_parameters=True,
use_default_for_missing_form_parameters=True,
)
messages = ToolEngine.generic_invoke(
tool=tool_runtime,
tool_parameters=dict(request.tool.tool_parameters),
user_id=request.caller.user_id,
workflow_tool_callback=DifyWorkflowCallbackHandler(),
workflow_call_depth=0,
conversation_id=request.caller.conversation_id,
app_id=request.caller.app_id,
)
transformed_messages = list(
ToolFileMessageTransformer.transform_tool_invoke_messages(
messages=messages,
user_id=request.caller.user_id,
tenant_id=request.caller.tenant_id,
conversation_id=request.caller.conversation_id,
)
)
except ToolProviderNotFoundError as exc:
raise AgentToolInnerServiceError(
error_code="agent_tool_declaration_not_found",
description=str(exc),
status_code=404,
) from exc
except ToolProviderCredentialValidationError as exc:
raise AgentToolInnerServiceError(
error_code="agent_tool_credential_invalid",
description=str(exc),
status_code=422,
) from exc
except ToolParameterValidationError as exc:
raise AgentToolInnerServiceError(
error_code="tool_parameters_invalid",
description=str(exc),
status_code=422,
) from exc
except (ToolInvokeError, ToolNotFoundError, ToolNotSupportedError) as exc:
raise AgentToolInnerServiceError(
error_code="agent_tool_invoke_failed",
description=str(exc),
status_code=422,
) from exc
except ValueError as exc:
raise _map_value_error(exc) from exc
except Exception as exc:
raise AgentToolInnerServiceError(
error_code="agent_tool_invoke_unexpected_error",
description=str(exc),
status_code=500,
) from exc
return AgentToolInvokeResponse(
messages=[message.model_dump(mode="json") for message in transformed_messages],
observation=ToolEngine.tool_response_to_str(transformed_messages),
metadata={
"provider_type": request.tool.provider_type,
"provider_id": request.tool.provider_id,
"tool_name": request.tool.tool_name,
},
)
def _map_value_error(error: ValueError) -> AgentToolInnerServiceError:
description = str(error)
if description == "app not found":
return AgentToolInnerServiceError(
error_code="app_not_found",
description="App not found.",
status_code=404,
)
return AgentToolInnerServiceError(
error_code="agent_tool_invoke_failed",
description=description,
status_code=422,
)
+221 -153
View File
@@ -38,6 +38,35 @@ if TYPE_CHECKING:
class AppGenerateService:
@classmethod
@trace_span(AppGenerateHandler)
def generate_stateless_agent_app(
cls,
*,
app_model: App,
user: Account | EndUser,
args: Mapping[str, Any],
invoke_from: InvokeFrom,
):
"""Run build-chat finalization as a blocking, non-SSE Agent App action.
This is the service entry point for the Agent build-chat finalize flow.
It applies the same tracing, quota, and rate-limit guardrails as normal
app generation, but invokes the Agent App generator in stateless mode:
the call waits synchronously for Agent backend completion, triggers only
the backend side effect, and does not create Dify chat/message records.
"""
return cls._run_with_guardrails(
app_model=app_model,
streaming=False,
action=lambda _rate_limit, _request_id: AgentAppGenerator().generate_stateless(
app_model=app_model,
user=user,
args=args,
invoke_from=invoke_from,
),
)
@staticmethod
def _build_streaming_task_on_subscribe(start_task: Callable[[], None]) -> Callable[[], None]:
"""
@@ -105,6 +134,29 @@ class AppGenerateService:
:param streaming: streaming
:return:
"""
return cls._run_with_guardrails(
app_model=app_model,
streaming=streaming,
action=lambda rate_limit, request_id: cls._dispatch_generate(
app_model=app_model,
user=user,
args=args,
invoke_from=invoke_from,
streaming=streaming,
root_node_id=root_node_id,
rate_limit=rate_limit,
request_id=request_id,
),
)
@classmethod
def _run_with_guardrails(
cls,
*,
app_model: App,
streaming: bool,
action: Callable[[RateLimit, str], Any],
):
quota_charge = unlimited()
if dify_config.BILLING_ENABLED:
try:
@@ -119,166 +171,182 @@ class AppGenerateService:
try:
request_id = rate_limit.enter(request_id)
quota_charge.commit()
effective_mode = (
AppMode.AGENT_CHAT if app_model.is_agent and app_model.mode != AppMode.AGENT_CHAT else app_model.mode
)
match effective_mode:
case AppMode.COMPLETION:
return rate_limit.generate(
CompletionAppGenerator.convert_to_event_stream(
CompletionAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id=request_id,
)
case AppMode.AGENT_CHAT:
return rate_limit.generate(
AgentChatAppGenerator.convert_to_event_stream(
AgentChatAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id,
)
case AppMode.AGENT:
return rate_limit.generate(
AgentAppGenerator.convert_to_event_stream(
AgentAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id,
)
case AppMode.CHAT:
return rate_limit.generate(
ChatAppGenerator.convert_to_event_stream(
ChatAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id=request_id,
)
case AppMode.ADVANCED_CHAT:
workflow_id = args.get("workflow_id")
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
if streaming:
# Streaming mode: subscribe to SSE and enqueue the execution on first subscriber
with rate_limit_context(rate_limit, request_id):
payload = AppExecutionParams.new(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=True,
call_depth=0,
workflow_run_id=str(uuid.uuid4()),
)
payload_json = payload.model_dump_json()
def on_subscribe():
workflow_based_app_execution_task.delay(payload_json)
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
generator = AdvancedChatAppGenerator()
return rate_limit.generate(
generator.convert_to_event_stream(
generator.retrieve_events(
AppMode.ADVANCED_CHAT,
payload.workflow_run_id,
on_subscribe=on_subscribe,
),
),
request_id=request_id,
)
else:
# Blocking mode: run synchronously and return JSON instead of SSE
# Keep behaviour consistent with WORKFLOW blocking branch.
pause_config = PauseStateLayerConfig(
session_factory=session_factory.get_session_maker(),
state_owner_user_id=workflow.created_by,
)
advanced_generator = AdvancedChatAppGenerator()
return rate_limit.generate(
advanced_generator.convert_to_event_stream(
advanced_generator.generate(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
workflow_run_id=str(uuid.uuid4()),
streaming=False,
pause_state_config=pause_config,
)
),
request_id=request_id,
)
case AppMode.WORKFLOW:
workflow_id = args.get("workflow_id")
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
if streaming:
with rate_limit_context(rate_limit, request_id):
payload = AppExecutionParams.new(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=True,
call_depth=0,
root_node_id=root_node_id,
workflow_run_id=str(uuid.uuid4()),
)
payload_json = payload.model_dump_json()
def on_subscribe():
workflow_based_app_execution_task.delay(payload_json)
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
return rate_limit.generate(
WorkflowAppGenerator.convert_to_event_stream(
MessageBasedAppGenerator.retrieve_events(
AppMode.WORKFLOW,
payload.workflow_run_id,
on_subscribe=on_subscribe,
),
),
request_id,
)
pause_config = PauseStateLayerConfig(
session_factory=session_factory.get_session_maker(),
state_owner_user_id=workflow.created_by,
)
return rate_limit.generate(
WorkflowAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().generate(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=False,
root_node_id=root_node_id,
call_depth=0,
pause_state_config=pause_config,
),
),
request_id,
)
case _:
raise ValueError(f"Invalid app mode {app_model.mode}")
return action(rate_limit, request_id)
except Exception:
quota_charge.refund()
rate_limit.exit(request_id)
if streaming:
rate_limit.exit(request_id)
raise
finally:
if not streaming:
rate_limit.exit(request_id)
@classmethod
def _dispatch_generate(
cls,
*,
app_model: App,
user: Account | EndUser,
args: Mapping[str, Any],
invoke_from: InvokeFrom,
streaming: bool,
root_node_id: str | None,
rate_limit: RateLimit,
request_id: str,
):
effective_mode = (
AppMode.AGENT_CHAT if app_model.is_agent and app_model.mode != AppMode.AGENT_CHAT else app_model.mode
)
match effective_mode:
case AppMode.COMPLETION:
return rate_limit.generate(
CompletionAppGenerator.convert_to_event_stream(
CompletionAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id=request_id,
)
case AppMode.AGENT_CHAT:
return rate_limit.generate(
AgentChatAppGenerator.convert_to_event_stream(
AgentChatAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id,
)
case AppMode.AGENT:
return rate_limit.generate(
AgentAppGenerator.convert_to_event_stream(
AgentAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id,
)
case AppMode.CHAT:
return rate_limit.generate(
ChatAppGenerator.convert_to_event_stream(
ChatAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id=request_id,
)
case AppMode.ADVANCED_CHAT:
workflow_id = args.get("workflow_id")
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
if streaming:
# Streaming mode: subscribe to SSE and enqueue the execution on first subscriber
with rate_limit_context(rate_limit, request_id):
payload = AppExecutionParams.new(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=True,
call_depth=0,
workflow_run_id=str(uuid.uuid4()),
)
payload_json = payload.model_dump_json()
def on_subscribe():
workflow_based_app_execution_task.delay(payload_json)
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
generator = AdvancedChatAppGenerator()
return rate_limit.generate(
generator.convert_to_event_stream(
generator.retrieve_events(
AppMode.ADVANCED_CHAT,
payload.workflow_run_id,
on_subscribe=on_subscribe,
),
),
request_id=request_id,
)
# Blocking mode: run synchronously and return JSON instead of SSE
# Keep behaviour consistent with WORKFLOW blocking branch.
pause_config = PauseStateLayerConfig(
session_factory=session_factory.get_session_maker(),
state_owner_user_id=workflow.created_by,
)
advanced_generator = AdvancedChatAppGenerator()
return rate_limit.generate(
advanced_generator.convert_to_event_stream(
advanced_generator.generate(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
workflow_run_id=str(uuid.uuid4()),
streaming=False,
pause_state_config=pause_config,
)
),
request_id=request_id,
)
case AppMode.WORKFLOW:
workflow_id = args.get("workflow_id")
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
if streaming:
with rate_limit_context(rate_limit, request_id):
payload = AppExecutionParams.new(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=True,
call_depth=0,
root_node_id=root_node_id,
workflow_run_id=str(uuid.uuid4()),
)
payload_json = payload.model_dump_json()
def on_subscribe():
workflow_based_app_execution_task.delay(payload_json)
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
return rate_limit.generate(
WorkflowAppGenerator.convert_to_event_stream(
MessageBasedAppGenerator.retrieve_events(
AppMode.WORKFLOW,
payload.workflow_run_id,
on_subscribe=on_subscribe,
),
),
request_id,
)
pause_config = PauseStateLayerConfig(
session_factory=session_factory.get_session_maker(),
state_owner_user_id=workflow.created_by,
)
return rate_limit.generate(
WorkflowAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().generate(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=False,
root_node_id=root_node_id,
call_depth=0,
pause_state_config=pause_config,
),
),
request_id,
)
case _:
raise ValueError(f"Invalid app mode {app_model.mode}")
@staticmethod
def _get_max_active_requests(app: App) -> int:
"""
+59
View File
@@ -0,0 +1,59 @@
"""Request/response DTOs for the Agent tool inner invoke API."""
from __future__ import annotations
from typing import ClassVar, Literal
from pydantic import BaseModel, ConfigDict, Field, JsonValue
type AgentToolProviderType = Literal["plugin", "builtin", "api", "workflow", "mcp"]
class AgentToolInvokeCaller(BaseModel):
tenant_id: str
user_id: str
user_from: str
app_id: str
invoke_from: str
conversation_id: str | None = None
workflow_id: str | None = None
workflow_run_id: str | None = None
node_id: str | None = None
node_execution_id: str | None = None
agent_id: str | None = None
agent_config_version_id: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentToolInvokeTarget(BaseModel):
"""One tool invocation payload.
`runtime_parameters` are API-prepared hidden/form/runtime values forwarded
into `ToolManager` runtime construction. `tool_parameters` are the live
LLM/user invocation arguments supplied at call time.
"""
provider_type: AgentToolProviderType
provider_id: str
tool_name: str
credential_id: str | None = None
tool_parameters: dict[str, JsonValue] = Field(default_factory=dict)
runtime_parameters: dict[str, JsonValue] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentToolInvokeRequest(BaseModel):
caller: AgentToolInvokeCaller
tool: AgentToolInvokeTarget
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentToolInvokeResponse(BaseModel):
messages: list[dict[str, JsonValue]] = Field(default_factory=list)
observation: str
metadata: dict[str, JsonValue] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
+13
View File
@@ -0,0 +1,13 @@
"""Domain errors for the Agent tool inner invoke boundary."""
class AgentToolInnerServiceError(ValueError):
error_code: str
description: str
status_code: int
def __init__(self, *, error_code: str, description: str, status_code: int) -> None:
self.error_code = error_code
self.description = description
self.status_code = status_code
super().__init__(description)
@@ -51,7 +51,7 @@ def resume_agent_app_execution(*, conversation_id: str, form_id: str) -> None:
app_model=app_model,
user=user,
conversation_id=conversation_id,
invoke_from=InvokeFrom.WEB_APP,
invoke_from=_resolve_invoke_from(conversation),
)
except Exception:
logger.exception("Agent App resume failed for conversation %s form %s", conversation_id, form_id)
@@ -68,3 +68,9 @@ def _resolve_conversation_user(*, app_model: App, conversation: Conversation) ->
if conversation.from_end_user_id:
return db.session.get(EndUser, conversation.from_end_user_id)
return None
def _resolve_invoke_from(conversation: Conversation) -> InvokeFrom:
if conversation.invoke_from is None:
return InvokeFrom.WEB_APP
return InvokeFrom.value_of(conversation.invoke_from.value)
@@ -7,6 +7,11 @@ from agenton.layers import ExitIntent
from agenton.layers.base import LifecycleState
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
from dify_agent.layers.dify_core_tools import (
DIFY_CORE_TOOLS_LAYER_TYPE_ID,
DifyCoreToolConfig,
DifyCoreToolsLayerConfig,
)
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
@@ -29,6 +34,7 @@ from pydantic import ValidationError
from clients.agent_backend import (
AGENT_SOUL_PROMPT_LAYER_ID,
DIFY_CORE_TOOLS_LAYER_ID,
DIFY_EXECUTION_CONTEXT_LAYER_ID,
DIFY_KNOWLEDGE_BASE_LAYER_ID,
DIFY_PLUGIN_TOOLS_LAYER_ID,
@@ -160,6 +166,30 @@ def test_request_builder_adds_dify_plugin_tools_layer_when_configured():
assert tools_config.tools[0].tool_name == "current_time"
def test_request_builder_adds_dify_core_tools_layer_when_configured():
run_input = _run_input()
run_input.core_tools = DifyCoreToolsLayerConfig(
tools=[
DifyCoreToolConfig(
provider_type="builtin",
provider_id="audio",
tool_name="transcribe",
name="transcribe",
description="Transcribe audio.",
runtime_parameters={"language": "en"},
parameters=[],
parameters_json_schema={"type": "object", "properties": {}, "required": []},
)
]
)
request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input)
layers = {layer.name: layer for layer in request.composition.layers}
assert layers[DIFY_CORE_TOOLS_LAYER_ID].type == DIFY_CORE_TOOLS_LAYER_TYPE_ID
assert layers[DIFY_CORE_TOOLS_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
def test_request_builder_adds_knowledge_layer_when_configured():
run_input = _run_input()
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
@@ -44,7 +44,12 @@ from controllers.console.agent.roster import (
)
from controllers.console.app import completion as completion_controller
from controllers.console.app import message as message_controller
from controllers.console.app.completion import AgentChatMessageApi, AgentChatMessageStopApi
from controllers.console.app.completion import (
AgentBuildChatFinalizeApi,
AgentChatMessageApi,
AgentChatMessageStopApi,
)
from controllers.console.app.error import CompletionRequestError
from controllers.console.app.message import (
AgentChatMessageListApi,
AgentMessageApi,
@@ -296,6 +301,11 @@ def test_agent_app_list_and_create_use_agent_route(
"load_or_create_agent_app_debug_conversation_ids_by_agent_id",
lambda _self, **kwargs: {"agent-list": "debug-conversation-list"},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"count_agent_app_debug_conversation_messages",
lambda _self, **kwargs: 0,
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_or_create_agent_app_debug_conversation_id",
@@ -407,6 +417,11 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
"get_or_create_agent_app_debug_conversation_id",
lambda _self, **kwargs: "debug-conversation-detail",
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"count_agent_app_debug_conversation_messages",
lambda _self, **kwargs: 2,
)
monkeypatch.setattr(
roster_controller.FeatureService,
"get_system_features",
@@ -431,6 +446,8 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
assert detail["id"] == agent_id
assert detail["app_id"] == "app-1"
assert detail["debug_conversation_id"] == "debug-conversation-detail"
assert detail["debug_conversation_has_messages"] is True
assert detail["debug_conversation_message_count"] == 2
assert detail["role"] == "Resolved role"
assert detail["active_config_is_published"] is False
assert "bound_agent_id" not in detail
@@ -445,6 +462,8 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
assert updated["id"] == agent_id
assert updated["app_id"] == "app-1"
assert updated["debug_conversation_id"] == "debug-conversation-detail"
assert updated["debug_conversation_has_messages"] is True
assert updated["debug_conversation_message_count"] == 2
assert updated["role"] == "Resolved role"
assert updated["active_config_is_published"] is False
assert "bound_agent_id" not in updated
@@ -529,7 +548,11 @@ def test_agent_debug_conversation_refresh_uses_current_user(
agent_id,
)
assert response == {"debug_conversation_id": "new-debug-conversation-id"}
assert response == {
"debug_conversation_id": "new-debug-conversation-id",
"debug_conversation_has_messages": False,
"debug_conversation_message_count": 0,
}
assert captured == {
"tenant_id": "tenant-1",
"agent_id": agent_id,
@@ -1141,9 +1164,10 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
with app.test_request_context("?snapshot_id=preview-version"):
workflow_state = unwrap(WorkflowAgentComposerApi.get)(
WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1"
WorkflowAgentComposerApi(), "tenant-1", account_id, app_model, "node-1"
)
assert workflow_state["node_id"] == "node-1"
assert captured_load["account_id"] == account_id
assert captured_load["snapshot_id"] == "preview-version"
with app.test_request_context(json=payload):
saved_state = unwrap(WorkflowAgentComposerApi.put)(
@@ -1341,6 +1365,132 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
assert stop_call == {"current_user_id": account_id, "app_model": app_model, "task_id": "task-1"}
def test_agent_build_chat_finalize_route_resolves_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent")
captured: dict[str, object] = {}
def resolve_agent_app_model(**kwargs: object) -> object:
captured["resolve"] = kwargs
return app_model
def create_finalization_message(**kwargs: object) -> dict[str, object]:
captured["finalize"] = kwargs
return {"result": "generated"}
monkeypatch.setattr(completion_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model)
monkeypatch.setattr(completion_controller, "_create_build_chat_finalization_message", create_finalization_message)
with app.test_request_context():
assert unwrap(AgentBuildChatFinalizeApi.post)(
AgentBuildChatFinalizeApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id
) == {"result": "generated"}
assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id}
finalize_call = cast(dict[str, object], captured["finalize"])
assert finalize_call["app_model"] is app_model
assert finalize_call["current_tenant_id"] == "tenant-1"
assert finalize_call["agent_id"] == agent_id
assert cast(SimpleNamespace, finalize_call["current_user"]).id == account_id
def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent")
captured: dict[str, object] = {}
def resolve_debug_conversation(**kwargs: object) -> str:
captured["resolve_debug_conversation"] = kwargs
return "debug-conversation-1"
def generate(**kwargs: object) -> object:
captured["generate"] = kwargs
return iter(
[
"event: ping\n\n",
'data: {"event":"message","answer":"working"}\n\n',
'data: {"event":"message_end"}\n\n',
]
)
monkeypatch.setattr(
completion_controller,
"_resolve_current_user_agent_debug_conversation_id",
resolve_debug_conversation,
)
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
with app.test_request_context(headers={"X-Trace-Id": "trace-1"}):
result = completion_controller._create_build_chat_finalization_message(
current_tenant_id="tenant-1",
current_user=SimpleNamespace(id=account_id),
app_model=app_model,
agent_id="agent-1",
)
assert result == ({"result": "success"}, 200)
assert captured["resolve_debug_conversation"] == {
"current_tenant_id": "tenant-1",
"current_user": SimpleNamespace(id=account_id),
"app_model": app_model,
"agent_id": "agent-1",
}
generate_call = cast(dict[str, object], captured["generate"])
assert generate_call["app_model"] is app_model
assert generate_call["streaming"] is True
args = cast(dict[str, object], generate_call["args"])
assert args["draft_type"] == "debug_build"
assert args["response_mode"] == "streaming"
assert args["conversation_id"] == "debug-conversation-1"
assert args["inputs"] == {}
assert args["auto_generate_name"] is False
assert args["external_trace_id"] == "trace-1"
def test_drain_streaming_generate_response_returns_on_message_end() -> None:
class ClosableResponse:
def __init__(self) -> None:
self._chunks = iter(
[
"event: ping\n\n",
'data: {"event":"message","answer":"working"}\n\n',
'data: {"event":"message_end","message_id":"msg-1"}\n\n',
]
)
self.closed = False
def __iter__(self):
return self
def __next__(self) -> str:
return next(self._chunks)
def close(self) -> None:
self.closed = True
response = ClosableResponse()
assert completion_controller._drain_streaming_generate_response(response) is None
assert response.closed is True
def test_drain_streaming_generate_response_maps_error_event() -> None:
response = iter(['data: {"event":"error","message":"backend failed"}\n\n'])
with pytest.raises(CompletionRequestError, match="backend failed"):
completion_controller._drain_streaming_generate_response(response)
def test_drain_streaming_generate_response_raises_when_stream_ends_early() -> None:
response = iter(['data: {"event":"message","answer":"working"}\n\n'])
with pytest.raises(CompletionRequestError, match="did not complete"):
completion_controller._drain_streaming_generate_response(response)
def test_agent_chat_helper_forces_agent_streaming_and_external_trace(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
@@ -0,0 +1,435 @@
"""Unit tests for the console agent config inspector routes.
These tests unwrap the Flask decorators and focus on version resolution,
workflow-node agent binding, and service delegation for the new config surface.
"""
from __future__ import annotations
import inspect
from types import SimpleNamespace
from unittest.mock import PropertyMock, patch
from flask import Flask
from controllers.console.app import agent_config_inspector as inspector
from controllers.console.app.agent_config_inspector import (
AgentConfigFileDownloadApi,
AgentConfigFilePreviewApi,
AgentConfigFilesApi,
AgentConfigFilesByAgentApi,
AgentConfigManifestApi,
AgentConfigManifestByAgentApi,
AgentConfigSkillFileDownloadApi,
AgentConfigSkillFileDownloadByAgentApi,
AgentConfigSkillFilePreviewByAgentApi,
AgentConfigSkillInspectByAgentApi,
AgentConfigSkillsApi,
AgentConfigSkillUploadByAgentApi,
console_ns,
)
from services.agent_config_service import AgentConfigServiceError
_MOD = "controllers.console.app.agent_config_inspector"
app = Flask(__name__)
def _raw(method):
return inspect.unwrap(method)
_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1")
_USER = SimpleNamespace(id="acct-1")
def test_manifest_by_agent_resolves_build_draft_version():
raw = _raw(AgentConfigManifestByAgentApi.get)
with app.test_request_context("/?draft_type=debug_build"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}}
config_service.return_value.manifest.return_value = {
"agent_id": "agent-1",
"config_version": {"id": "build-draft-1", "kind": "build_draft", "writable": True},
"skills": {"items": []},
"files": {"items": []},
"env_keys": [],
"note": "",
}
body = raw(AgentConfigManifestByAgentApi(), "tenant-1", _USER, "agent-1")
assert body["config_version"]["kind"] == "build_draft"
assert config_service.return_value.manifest.call_args.kwargs["config_version_id"] == "build-draft-1"
assert config_service.return_value.manifest.call_args.kwargs["config_version_kind"].value == "build_draft"
def test_manifest_resolves_workflow_node_agent_and_normal_draft():
raw = _raw(AgentConfigManifestApi.get)
with app.test_request_context("/?node_id=node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}}
config_service.return_value.manifest.return_value = {
"agent_id": "wf-agent-9",
"config_version": {"id": "draft-1", "kind": "draft", "writable": True},
"skills": {"items": []},
"files": {"items": []},
"env_keys": [],
"note": "",
}
body = raw(AgentConfigManifestApi(), _USER, _APP)
assert body["agent_id"] == "wf-agent-9"
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "node-1"
assert config_service.return_value.manifest.call_args.kwargs["config_version_kind"].value == "draft"
def test_normal_draft_resolution_commits_created_draft_before_service_session() -> None:
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.db") as db,
):
composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}}
version_id, version_kind = inspector._resolve_console_version(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="acct-1",
version_id=None,
draft_type="draft",
)
assert version_id == "draft-1"
assert version_kind.value == "draft"
db.session.commit.assert_called_once()
def test_skill_inspect_by_agent_returns_strict_json_response():
raw = _raw(AgentConfigSkillInspectByAgentApi.get)
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}}
config_service.return_value.inspect_skill.return_value = {
"id": "pdf-toolkit",
"name": "pdf-toolkit",
"description": "Work with PDFs.",
"size": 42,
"mime_type": "application/zip",
"hash": "sha256:abc",
"source": "config_skill_zip",
"files": [
{
"path": "SKILL.md",
"name": "SKILL.md",
"type": "file",
"previewable": True,
"downloadable": True,
}
],
"skill_md": {
"path": "SKILL.md",
"size": 24,
"truncated": False,
"binary": False,
"text": "# PDF Toolkit\nUse it.\n",
},
"warnings": [],
}
response = raw(AgentConfigSkillInspectByAgentApi(), "tenant-1", _USER, "agent-1", "pdf-toolkit")
assert response.status_code == 200
assert response.get_json()["name"] == "pdf-toolkit"
assert b"PDF Toolkit" in response.get_data()
def test_file_preview_api_passes_through_and_maps_errors():
raw = _raw(AgentConfigFilePreviewApi.get)
with app.test_request_context("/?node_id=node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}}
config_service.return_value.preview_file.return_value = {
"name": "sample.txt",
"size": 5,
"truncated": False,
"binary": False,
"text": "hello",
}
body = raw(AgentConfigFilePreviewApi(), _USER, _APP, "sample.txt")
assert body["text"] == "hello"
with app.test_request_context("/?node_id=node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}}
config_service.return_value.preview_file.side_effect = AgentConfigServiceError(
"config_file_not_found", "missing", status_code=404
)
body, status = raw(AgentConfigFilePreviewApi(), _USER, _APP, "missing.txt")
assert status == 404
assert body["code"] == "config_file_not_found"
def test_skill_upload_by_agent_delegates_after_version_resolution():
raw = _raw(AgentConfigSkillUploadByAgentApi.post)
with app.test_request_context("/?draft_type=debug_build"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentComposerService") as composer,
patch(
f"{_MOD}._upload_skill_for_target",
return_value=(
{
"skill": {"id": "alpha", "name": "alpha", "file_id": "tool-file-1", "description": "Alpha"},
"config_version": {"id": "build-draft-1", "kind": "build_draft", "writable": True},
},
201,
),
) as upload_skill,
):
composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}}
body, status = raw(AgentConfigSkillUploadByAgentApi(), "tenant-1", _USER, "agent-1")
assert status == 201
assert body["skill"]["name"] == "alpha"
assert upload_skill.call_args.kwargs["version_id"] == "build-draft-1"
assert upload_skill.call_args.kwargs["version_kind"].value == "build_draft"
def test_file_upload_by_agent_delegates_to_service_owned_upload_lookup():
raw = _raw(AgentConfigFilesByAgentApi.post)
with app.test_request_context("/?draft_type=debug_build"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentComposerService") as composer,
patch.object(
type(console_ns),
"payload",
new_callable=PropertyMock,
return_value={"upload_file_id": "upload-1"},
),
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}}
config_service.return_value.push_file_for_console.return_value = {
"file": {"id": "guide.txt", "name": "guide.txt", "file_id": "upload-1"},
"config_version": {"id": "build-draft-1", "kind": "build_draft", "writable": True},
}
body, status = raw(AgentConfigFilesByAgentApi(), "tenant-1", _USER, "agent-1")
assert status == 201
assert body["file"]["name"] == "guide.txt"
assert config_service.return_value.push_file_for_console.call_args.kwargs["upload_file_id"] == "upload-1"
assert config_service.return_value.push_file_for_console.call_args.kwargs["config_version_id"] == "build-draft-1"
assert (
config_service.return_value.push_file_for_console.call_args.kwargs["config_version_kind"].value == "build_draft"
)
def test_skill_list_api_uses_config_list_shape() -> None:
raw = _raw(AgentConfigSkillsApi.get)
with app.test_request_context("/?node_id=node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}}
config_service.return_value.list_skills.return_value = {
"agent_id": "wf-agent-9",
"config_version": {"id": "draft-1", "kind": "draft", "writable": True},
"items": [{"id": "alpha", "name": "alpha", "file_id": "tool-file-1", "description": "Alpha"}],
}
body = raw(AgentConfigSkillsApi(), _USER, _APP)
assert body["items"][0]["name"] == "alpha"
assert body["items"][0]["file_id"] == "tool-file-1"
assert config_service.return_value.list_skills.call_args.kwargs["agent_id"] == "wf-agent-9"
def test_file_list_api_uses_config_list_shape() -> None:
raw = _raw(AgentConfigFilesApi.get)
with app.test_request_context("/?node_id=node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}}
config_service.return_value.list_files.return_value = {
"agent_id": "wf-agent-9",
"config_version": {"id": "draft-1", "kind": "draft", "writable": True},
"items": [
{
"id": "guide.txt",
"name": "guide.txt",
"file_id": "upload-file-1",
"hash": "sha256:file-1",
"mime_type": "text/plain",
"size": 7,
}
],
}
body = raw(AgentConfigFilesApi(), _USER, _APP)
assert body == {
"agent_id": "wf-agent-9",
"config_version": {"id": "draft-1", "kind": "draft", "writable": True},
"items": [
{
"id": "guide.txt",
"name": "guide.txt",
"file_id": "upload-file-1",
"hash": "sha256:file-1",
"mime_type": "text/plain",
"size": 7,
}
],
}
assert config_service.return_value.list_files.call_args.kwargs["agent_id"] == "wf-agent-9"
def test_skill_file_preview_by_agent_reads_path_query() -> None:
raw = _raw(AgentConfigSkillFilePreviewByAgentApi.get)
with app.test_request_context("/?draft_type=debug_build&path=references/guide.md"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}}
config_service.return_value.preview_skill_file.return_value = {
"path": "references/guide.md",
"size": 11,
"truncated": False,
"binary": False,
"text": "hello world",
}
body = raw(AgentConfigSkillFilePreviewByAgentApi(), "tenant-1", _USER, "agent-1", "alpha")
assert body["path"] == "references/guide.md"
assert config_service.return_value.preview_skill_file.call_args.kwargs["path"] == "references/guide.md"
def test_skill_file_download_by_agent_returns_proxy_url() -> None:
raw = _raw(AgentConfigSkillFileDownloadByAgentApi.get)
with app.test_request_context("/?draft_type=debug_build&path=references/guide.md"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
patch(
f"{_MOD}.url_for",
return_value="/console/api/agent/agent-1/config/skills/alpha/files/content?path=references%2Fguide.md&draft_type=debug_build",
),
):
composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}}
config_service.return_value.resolve_skill_file_member_path.return_value = "references/guide.md"
response = raw(AgentConfigSkillFileDownloadByAgentApi(), "tenant-1", _USER, "agent-1", "alpha")
assert response.status_code == 200
assert response.get_json()["url"].endswith(
"/agent/agent-1/config/skills/alpha/files/content?path=references%2Fguide.md&draft_type=debug_build"
)
def test_skill_file_download_by_agent_validates_member_path() -> None:
raw = _raw(AgentConfigSkillFileDownloadByAgentApi.get)
with app.test_request_context("/?draft_type=debug_build&path=references/missing.md"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}}
config_service.return_value.resolve_skill_file_member_path.side_effect = AgentConfigServiceError(
"config_skill_file_not_found", "missing", status_code=404
)
body, status = raw(AgentConfigSkillFileDownloadByAgentApi(), "tenant-1", _USER, "agent-1", "alpha")
assert status == 404
assert body["code"] == "config_skill_file_not_found"
def test_skill_file_download_api_forwards_workflow_node_and_draft_type() -> None:
raw = _raw(AgentConfigSkillFileDownloadApi.get)
with app.test_request_context("/?node_id=node-1&draft_type=debug_build&path=references/guide.md"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
patch(
f"{_MOD}.url_for",
return_value=(
"/console/api/apps/app-1/agent/config/skills/alpha/files/content"
"?node_id=node-1&draft_type=debug_build&path=references%2Fguide.md"
),
) as url_for_mock,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}}
config_service.return_value.resolve_skill_file_member_path.return_value = "references/guide.md"
response = raw(AgentConfigSkillFileDownloadApi(), _USER, _APP, "alpha")
assert response.status_code == 200
assert response.get_json()["url"].endswith(
"/apps/app-1/agent/config/skills/alpha/files/content"
"?node_id=node-1&draft_type=debug_build&path=references%2Fguide.md"
)
assert url_for_mock.call_args.kwargs == {
"_external": False,
"app_id": "app-1",
"draft_type": "debug_build",
"name": "alpha",
"node_id": "node-1",
"path": "references/guide.md",
}
def test_skill_file_download_api_propagates_member_lookup_404s() -> None:
raw = _raw(AgentConfigSkillFileDownloadApi.get)
with app.test_request_context("/?node_id=node-1&path=references/missing.md"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}}
config_service.return_value.resolve_skill_file_member_path.side_effect = AgentConfigServiceError(
"config_skill_file_not_found", "missing", status_code=404
)
body, status = raw(AgentConfigSkillFileDownloadApi(), _USER, _APP, "alpha")
assert status == 404
assert body["code"] == "config_skill_file_not_found"
def test_file_download_api_returns_signed_url_json() -> None:
raw = _raw(AgentConfigFileDownloadApi.get)
with app.test_request_context("/?node_id=node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentConfigService") as config_service,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}}
config_service.return_value.download_file_url.return_value = "https://example.com/guide.txt"
response = raw(AgentConfigFileDownloadApi(), _USER, _APP, "guide.txt")
assert response.status_code == 200
assert response.get_json() == {"url": "https://example.com/guide.txt"}
@@ -0,0 +1,303 @@
"""Unit tests for the Agent config inner-API controller."""
from __future__ import annotations
import inspect
from types import SimpleNamespace
from unittest.mock import patch
from flask import Flask
from controllers.inner_api.plugin.agent_config import (
AgentConfigEnvApi,
AgentConfigFilePullApi,
AgentConfigManifestApi,
AgentConfigNoteApi,
AgentConfigPushApi,
AgentConfigSkillInspectApi,
AgentConfigSkillPullApi,
)
from services.agent_config_service import AgentConfigServiceError
MODULE = "controllers.inner_api.plugin.agent_config"
app = Flask(__name__)
def _raw(method):
return inspect.unwrap(method)
def test_manifest_happy_path_calls_service() -> None:
raw = _raw(AgentConfigManifestApi.get)
with app.test_request_context(
"/?tenant_id=tenant-1&user_id=user-1&config_version_id=cfg-1&config_version_kind=build_draft"
):
with patch(f"{MODULE}.AgentConfigService") as service:
service.return_value.manifest.return_value = {
"agent_id": "agent-1",
"config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True},
"skills": {
"items": [
{
"id": "alpha",
"name": "alpha",
"file_id": "tool-file-1",
"description": "Alpha skill",
"size": 123,
"hash": "sha256:abc",
"mime_type": "application/zip",
}
]
},
"files": {
"items": [
{
"id": "guide.txt",
"name": "guide.txt",
"file_id": "upload-file-1",
"size": 7,
"hash": "sha256:def",
"mime_type": "text/plain",
}
]
},
"env_keys": ["KEY"],
"note": "hello",
}
body = raw(AgentConfigManifestApi(), "agent-1")
assert body == {
"agent_id": "agent-1",
"config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True},
"skills": {
"items": [
{
"id": "alpha",
"name": "alpha",
"file_id": "tool-file-1",
"description": "Alpha skill",
"size": 123,
"hash": "sha256:abc",
"mime_type": "application/zip",
}
]
},
"files": {
"items": [
{
"id": "guide.txt",
"name": "guide.txt",
"file_id": "upload-file-1",
"size": 7,
"hash": "sha256:def",
"mime_type": "text/plain",
}
]
},
"env_keys": ["KEY"],
"note": "hello",
}
assert service.return_value.manifest.call_args.kwargs == {
"tenant_id": "tenant-1",
"agent_id": "agent-1",
"user_id": "user-1",
"config_version_id": "cfg-1",
"config_version_kind": service.return_value.manifest.call_args.kwargs["config_version_kind"],
}
assert service.return_value.manifest.call_args.kwargs["config_version_kind"].value == "build_draft"
def test_skill_pull_returns_send_file_response() -> None:
raw = _raw(AgentConfigSkillPullApi.get)
with app.test_request_context(
"/?tenant_id=tenant-1&user_id=user-1&config_version_id=cfg-1&config_version_kind=build_draft"
):
with patch(f"{MODULE}.AgentConfigService") as service:
service.return_value.pull_skill.return_value = SimpleNamespace(
payload=b"zip-bytes",
mime_type="application/zip",
filename="alpha.zip",
)
response = raw(AgentConfigSkillPullApi(), "agent-1", "alpha")
response.direct_passthrough = False
assert response.status_code == 200
assert response.mimetype == "application/zip"
assert response.get_data() == b"zip-bytes"
assert "filename=alpha.zip" in response.headers["Content-Disposition"]
assert service.return_value.pull_skill.call_args.kwargs["user_id"] == "user-1"
def test_skill_inspect_happy_path_returns_service_payload() -> None:
raw = _raw(AgentConfigSkillInspectApi.get)
with app.test_request_context(
"/?tenant_id=tenant-1&user_id=user-1&config_version_id=cfg-1&config_version_kind=build_draft"
):
with patch(f"{MODULE}.AgentConfigService") as service:
service.return_value.inspect_skill.return_value = {"name": "alpha", "files": ["SKILL.md"]}
body = raw(AgentConfigSkillInspectApi(), "agent-1", "alpha")
assert body == {"name": "alpha", "files": ["SKILL.md"]}
assert service.return_value.inspect_skill.call_args.kwargs["user_id"] == "user-1"
def test_file_pull_returns_send_file_response() -> None:
raw = _raw(AgentConfigFilePullApi.get)
with app.test_request_context(
"/?tenant_id=tenant-1&user_id=user-1&config_version_id=cfg-1&config_version_kind=build_draft"
):
with patch(f"{MODULE}.AgentConfigService") as service:
service.return_value.pull_file.return_value = SimpleNamespace(
payload=b"file-bytes",
mime_type="text/plain",
filename="guide.txt",
)
response = raw(AgentConfigFilePullApi(), "agent-1", "guide.txt")
response.direct_passthrough = False
assert response.status_code == 200
assert response.mimetype == "text/plain"
assert response.get_data() == b"file-bytes"
assert "filename=guide.txt" in response.headers["Content-Disposition"]
assert service.return_value.pull_file.call_args.kwargs["user_id"] == "user-1"
def test_push_happy_path_validates_body_and_preserves_execution_user() -> None:
raw = _raw(AgentConfigPushApi.post)
payload = {
"tenant_id": "tenant-1",
"user_id": "account-user-1",
"config_version_id": "cfg-1",
"config_version_kind": "build_draft",
"files": [{"name": "guide.txt", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}],
"skills": [],
"env_text": "KEY=value\n",
"note": "hello",
}
with app.test_request_context("/", method="POST", json=payload):
with patch(f"{MODULE}.AgentConfigService") as service:
service.return_value.push.return_value = {
"agent_id": "agent-1",
"config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True},
"skills": {"items": []},
"files": {"items": []},
"env_keys": ["KEY"],
"note": "hello",
}
body = raw(AgentConfigPushApi(), "agent-1")
assert body == {
"agent_id": "agent-1",
"config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True},
"skills": {"items": []},
"files": {"items": []},
"env_keys": ["KEY"],
"note": "hello",
}
assert service.return_value.push.call_args.kwargs["user_id"] == "account-user-1"
assert service.return_value.push.call_args.kwargs["config_version_kind"].value == "build_draft"
def test_env_happy_path_calls_service() -> None:
raw = _raw(AgentConfigEnvApi.patch)
payload = {
"tenant_id": "tenant-1",
"user_id": "account-user-1",
"config_version_id": "cfg-1",
"config_version_kind": "build_draft",
"env_text": "KEY=value\n",
}
with app.test_request_context("/", method="PATCH", json=payload):
with patch(f"{MODULE}.AgentConfigService") as service:
service.return_value.update_env.return_value = {"env_keys": ["KEY"]}
body = raw(AgentConfigEnvApi(), "agent-1")
assert body == {"env_keys": ["KEY"]}
assert service.return_value.update_env.call_args.kwargs["user_id"] == "account-user-1"
assert service.return_value.update_env.call_args.kwargs["env_text"] == "KEY=value\n"
def test_note_happy_path_calls_service() -> None:
raw = _raw(AgentConfigNoteApi.put)
payload = {
"tenant_id": "tenant-1",
"user_id": "account-user-1",
"config_version_id": "cfg-1",
"config_version_kind": "build_draft",
"note": "hello",
}
with app.test_request_context("/", method="PUT", json=payload):
with patch(f"{MODULE}.AgentConfigService") as service:
service.return_value.update_note.return_value = {"note": "hello"}
body = raw(AgentConfigNoteApi(), "agent-1")
assert body == {"note": "hello"}
assert service.return_value.update_note.call_args.kwargs["user_id"] == "account-user-1"
assert service.return_value.update_note.call_args.kwargs["note"] == "hello"
def test_manifest_invalid_query_returns_400() -> None:
raw = _raw(AgentConfigManifestApi.get)
with app.test_request_context("/?tenant_id=tenant-1"):
body, status = raw(AgentConfigManifestApi(), "agent-1")
assert status == 400
assert body["code"] == "invalid_request"
def test_push_invalid_body_returns_400() -> None:
raw = _raw(AgentConfigPushApi.post)
with app.test_request_context("/", method="POST", json={"tenant_id": "tenant-1"}):
body, status = raw(AgentConfigPushApi(), "agent-1")
assert status == 400
assert body["code"] == "invalid_request"
def test_manifest_maps_service_errors() -> None:
raw = _raw(AgentConfigManifestApi.get)
with app.test_request_context("/?tenant_id=tenant-1&config_version_id=cfg-1&config_version_kind=build_draft"):
with patch(f"{MODULE}.AgentConfigService") as service:
service.return_value.manifest.side_effect = AgentConfigServiceError(
"config_version_not_found",
"missing",
status_code=404,
)
body, status = raw(AgentConfigManifestApi(), "agent-1")
assert status == 404
assert body == {"code": "config_version_not_found", "message": "missing"}
def test_push_maps_service_errors() -> None:
raw = _raw(AgentConfigPushApi.post)
payload = {
"tenant_id": "tenant-1",
"user_id": "account-user-1",
"config_version_id": "cfg-1",
"config_version_kind": "build_draft",
"files": [],
"skills": [],
}
with app.test_request_context("/", method="POST", json=payload):
with patch(f"{MODULE}.AgentConfigService") as service:
service.return_value.push.side_effect = AgentConfigServiceError(
"config_not_writable",
"denied",
status_code=403,
)
body, status = raw(AgentConfigPushApi(), "agent-1")
assert status == 403
assert body == {"code": "config_not_writable", "message": "denied"}
@@ -0,0 +1,124 @@
"""Unit tests for the Agent tool inner API controller."""
from collections.abc import Iterator
from contextlib import contextmanager
from unittest.mock import patch
from flask import Flask
from controllers.inner_api import bp as inner_api_bp
from services.entities.agent_tool_inner import AgentToolInvokeResponse
from services.errors.agent_tool_inner import AgentToolInnerServiceError
def _headers(api_key: str | None = "inner-key") -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if api_key is not None:
headers["X-Inner-Api-Key"] = api_key
return headers
def _payload() -> dict[str, object]:
return {
"caller": {
"tenant_id": "tenant-1",
"user_id": "user-1",
"user_from": "account",
"app_id": "app-1",
"invoke_from": "service-api",
"conversation_id": "conversation-1",
"workflow_id": "workflow-1",
"workflow_run_id": "workflow-run-1",
"node_id": "node-1",
"node_execution_id": "node-exec-1",
"agent_id": "agent-1",
"agent_config_version_id": "snapshot-1",
},
"tool": {
"provider_type": "plugin",
"provider_id": "langgenius/search/search",
"tool_name": "search",
"credential_id": "credential-1",
"tool_parameters": {"query": "dify"},
"runtime_parameters": {"region": "us"},
},
}
@contextmanager
def _agent_inner_auth() -> Iterator[None]:
with (
patch("configs.dify_config.PLUGIN_DAEMON_KEY", "plugin-daemon-key"),
patch("configs.dify_config.INNER_API_KEY_FOR_PLUGIN", "inner-key"),
):
yield
def test_post_returns_service_response() -> None:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(inner_api_bp)
with (
_agent_inner_auth(),
patch("controllers.inner_api.agent.tools.AgentToolInnerService.invoke") as mock_invoke,
):
mock_invoke.return_value = AgentToolInvokeResponse(
messages=[{"type": "text", "message": {"text": "ok"}}],
observation="ok",
metadata={"provider_type": "plugin", "tool_name": "search"},
)
response = app.test_client().post(
"/inner/api/agent/tools/invoke",
json=_payload(),
headers=_headers(),
)
assert response.status_code == 200
body = response.get_json()
assert body["observation"] == "ok"
assert body["metadata"]["provider_type"] == "plugin"
def test_post_returns_400_for_invalid_body() -> None:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(inner_api_bp)
with _agent_inner_auth():
response = app.test_client().post(
"/inner/api/agent/tools/invoke",
json={"caller": {}},
headers=_headers(),
)
assert response.status_code == 400
body = response.get_json()
assert body["code"] == "invalid_request"
def test_post_preserves_service_error_status_code_and_description() -> None:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(inner_api_bp)
with (
_agent_inner_auth(),
patch("controllers.inner_api.agent.tools.AgentToolInnerService.invoke") as mock_invoke,
):
mock_invoke.side_effect = AgentToolInnerServiceError(
error_code="app_tenant_mismatch",
description="App does not belong to the caller tenant.",
status_code=403,
)
response = app.test_client().post(
"/inner/api/agent/tools/invoke",
json=_payload(),
headers=_headers(),
)
assert response.status_code == 403
body = response.get_json()
assert body["code"] == "app_tenant_mismatch"
assert body["message"] == "App does not belong to the caller tenant."
@@ -10,13 +10,20 @@ manager is replaced with a no-op so the thread body can run inline.
from __future__ import annotations
import contextlib
import json
from types import SimpleNamespace
import pytest
from pytest_mock import MockerFixture
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError
from core.app.apps.agent_app.app_generator import (
AgentAppGenerator,
AgentAppGeneratorError,
)
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from models import Account
from models.agent import AgentConfigDraftType
MODULE = "core.app.apps.agent_app.app_generator"
@@ -66,7 +73,7 @@ class TestGenerateGuards:
class TestGenerateSuccess:
def test_runtime_session_snapshot_id_is_stable_for_debugger_only(self):
def test_runtime_session_snapshot_id_preserves_snapshot_for_debugger_and_web_app(self):
assert (
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1")
== "snap-1"
@@ -81,7 +88,7 @@ class TestGenerateSuccess:
user = DummyAccount("user")
generator._resolve_agent = mocker.MagicMock(
return_value=(mocker.MagicMock(id="agent1"), mocker.MagicMock(id="snap1"), mocker.MagicMock())
return_value=(mocker.MagicMock(id="agent1"), "snap1", "snapshot", mocker.MagicMock())
)
generator._prepare_user_inputs = mocker.MagicMock(return_value={"x": 1})
generator._init_generate_records = mocker.MagicMock(
@@ -95,16 +102,26 @@ class TestGenerateSuccess:
)
mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock(model="gpt-4o-mini"))
mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user"))
generate_entity = mocker.patch(
f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user")
)
mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock())
thread_obj = mocker.MagicMock()
mocker.patch(f"{MODULE}.threading.Thread", return_value=thread_obj)
mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert", return_value={"result": "ok"})
file_mappings = [
{
"type": "image",
"transfer_method": "local_file",
"url": "",
"upload_file_id": "upload-file-1",
}
]
result = generator.generate(
app_model=app_model,
user=user,
args={"query": "hello", "inputs": {"name": "world"}},
args={"query": "hello", "inputs": {"name": "world"}, "files": file_mappings},
invoke_from=InvokeFrom.WEB_APP,
streaming=True,
)
@@ -117,11 +134,12 @@ class TestGenerateSuccess:
draft_type=None,
user=user,
)
assert generate_entity.call_args.kwargs["prompt_file_mappings"] == file_mappings
def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
generator._resolve_agent = mocker.MagicMock(
return_value=(mocker.MagicMock(id="a"), mocker.MagicMock(id="s"), mocker.MagicMock())
return_value=(mocker.MagicMock(id="a"), "snap1", "snapshot", mocker.MagicMock())
)
generator._prepare_user_inputs = mocker.MagicMock(return_value={})
generator._init_generate_records = mocker.MagicMock(
@@ -156,7 +174,7 @@ class TestGenerateSuccess:
user = DummyAccount("user")
generator._resolve_agent = mocker.MagicMock(
return_value=(mocker.MagicMock(id="agent1"), mocker.MagicMock(id="snap1"), mocker.MagicMock())
return_value=(mocker.MagicMock(id="agent1"), "snap1", "snapshot", mocker.MagicMock())
)
generator._prepare_user_inputs = mocker.MagicMock(return_value={})
generator._init_generate_records = mocker.MagicMock(
@@ -187,6 +205,81 @@ class TestGenerateSuccess:
assert generate_entity.call_args.kwargs["extras"] == {"auto_generate_conversation_name": True}
def test_generate_stateless_skips_chat_records(self, generator: AgentAppGenerator, mocker: MockerFixture):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
user = DummyAccount("user")
generator._resolve_agent = mocker.MagicMock(
return_value=(mocker.MagicMock(id="agent1"), "build-draft-1", "build_draft", mocker.MagicMock())
)
generator._init_generate_records = mocker.MagicMock()
run_stateless = mocker.patch.object(generator, "_run_stateless", return_value={"result": "success"})
converter = mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert")
result = generator.generate_stateless(
app_model=app_model,
user=user,
args={
"query": "finalize",
"inputs": {},
"conversation_id": "debug-conversation-1",
"draft_type": "debug_build",
},
invoke_from=InvokeFrom.DEBUGGER,
)
assert result == {"result": "success"}
generator._init_generate_records.assert_not_called()
converter.assert_not_called()
run_call = run_stateless.call_args.kwargs
assert run_call["conversation_id"] == "debug-conversation-1"
assert run_call["runtime_session_snapshot_id"] == "build-draft-1"
def test_generate_stateless_requires_conversation_id(self, generator: AgentAppGenerator, mocker: MockerFixture):
with pytest.raises(AgentAppGeneratorError, match="conversation_id is required"):
generator.generate_stateless(
app_model=mocker.MagicMock(),
user=DummyAccount("user"),
args={"query": "finalize", "inputs": {}, "draft_type": "debug_build"},
invoke_from=InvokeFrom.DEBUGGER,
)
def test_stateless_run_uses_agent_app_runner(self, generator: AgentAppGenerator, mocker: MockerFixture):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", app_model_config=mocker.MagicMock())
user = DummyAccount("user")
agent = mocker.MagicMock(id="agent1")
dify_context = SimpleNamespace(tenant_id="tenant", app_id="app1")
mocker.patch(f"{MODULE}.DifyRunContext", return_value=dify_context)
runner = mocker.MagicMock()
build_runner = mocker.patch.object(generator, "_build_runner", return_value=runner)
result = generator._run_stateless(
app_model=app_model,
user=user,
invoke_from=InvokeFrom.DEBUGGER,
query="finalize",
conversation_id="debug-conversation-1",
agent=agent,
agent_config_id="build-draft-1",
agent_config_version_kind="build_draft",
agent_soul=mocker.MagicMock(),
runtime_session_snapshot_id="build-draft-1",
)
assert result == {"result": "success"}
build_runner.assert_called_once_with(dify_context)
runner.run_stateless.assert_called_once_with(
dify_context=dify_context,
agent_id="agent1",
agent_config_snapshot_id="build-draft-1",
agent_config_version_kind="build_draft",
agent_soul=mocker.ANY,
conversation_id="debug-conversation-1",
query="finalize",
idempotency_key=mocker.ANY,
session_scope_snapshot_id="build-draft-1",
)
class TestGenerateWorker:
@pytest.fixture(autouse=True)
@@ -197,10 +290,18 @@ class TestGenerateWorker:
mocker.patch("libs.flask_utils.preserve_flask_contexts", ctx_manager)
def _wire(self, generator: AgentAppGenerator, mocker: MockerFixture, *, run_side_effect=None, handled=False):
def _wire(
self,
generator: AgentAppGenerator,
mocker: MockerFixture,
*,
run_side_effect=None,
handled=False,
guard_query="query",
):
generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock(id="conv"))
generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock(id="msg"))
generator._run_input_guards = mocker.MagicMock(return_value=(handled, "query"))
generator._run_input_guards = mocker.MagicMock(return_value=(handled, guard_query))
generator._resolve_agent_by_id = mocker.MagicMock(
return_value=(mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock())
)
@@ -227,6 +328,7 @@ class TestGenerateWorker:
is_resume=False,
query="query",
runtime_session_snapshot_id="s",
prompt_file_mappings=(),
):
generator._generate_worker(
flask_app=mocker.MagicMock(),
@@ -237,6 +339,7 @@ class TestGenerateWorker:
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
model_conf=mocker.MagicMock(model="m"),
query=query,
prompt_file_mappings=prompt_file_mappings,
),
queue_manager=queue_manager,
conversation_id="conv",
@@ -261,6 +364,30 @@ class TestGenerateWorker:
assert runner.run.call_args.kwargs["agent_config_snapshot_id"] == "s"
assert runner.run.call_args.kwargs["session_scope_snapshot_id"] is None
def test_worker_appends_prompt_files_to_backend_query(self, generator, mocker: MockerFixture):
runner = self._wire(generator, mocker, guard_query="你看得见这张图片吗")
queue_manager = mocker.MagicMock()
file_mappings = [
{
"type": "image",
"transfer_method": "local_file",
"url": "",
"upload_file_id": "upload-file-1",
}
]
self._call(
generator,
mocker,
queue_manager,
query="你看得见这张图片吗",
prompt_file_mappings=file_mappings,
)
assert runner.run.call_args.kwargs["query"] == (
f"你看得见这张图片吗\n{json.dumps(file_mappings, ensure_ascii=False)}"
)
def test_input_guard_short_circuit_skips_backend(self, generator, mocker: MockerFixture):
runner = self._wire(generator, mocker, handled=True)
queue_manager = mocker.MagicMock()
@@ -300,18 +427,22 @@ class TestResumeAfterFormSubmission:
def _wire(self, generator, mocker: MockerFixture):
generator._resolve_agent = mocker.MagicMock(
return_value=(mocker.MagicMock(id="agent1"), mocker.MagicMock(id="snap1"), mocker.MagicMock())
return_value=(mocker.MagicMock(id="agent1"), "snap1", "draft", mocker.MagicMock())
)
generator._init_generate_records = mocker.MagicMock(
return_value=(mocker.MagicMock(id="conv", mode="agent"), mocker.MagicMock(id="msg"))
)
generator._handle_response = mocker.MagicMock(return_value=None)
mocker.patch(f"{MODULE}.ConversationService.get_conversation", return_value=mocker.MagicMock(id="conv"))
mocker.patch(
f"{MODULE}.ConversationService.get_conversation",
return_value=mocker.MagicMock(id="conv", invoke_from=InvokeFrom.WEB_APP),
)
mocker.patch(f"{MODULE}.AgentAppConfigManager.get_app_config", return_value=mocker.MagicMock(variables=[]))
mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.AgentAppRuntimeSessionStore")
return mocker.patch(
f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user")
)
@@ -345,3 +476,26 @@ class TestResumeAfterFormSubmission:
# No prior user message -> a non-blank placeholder, still never blank.
assert entity.call_args.kwargs["query"] == "(resumed)"
def test_resume_uses_build_draft_for_debugger_conversation(self, generator, mocker: MockerFixture):
self._wire(generator, mocker)
conversation = mocker.MagicMock(id="conv", invoke_from=InvokeFrom.DEBUGGER)
mocker.patch(f"{MODULE}.ConversationService.get_conversation", return_value=conversation)
session_store = mocker.patch(f"{MODULE}.AgentAppRuntimeSessionStore")
session_store.return_value.load_active_session_for_conversation.return_value = mocker.MagicMock(
scope=mocker.MagicMock(agent_config_snapshot_id="draft-build-1")
)
draft_row = mocker.MagicMock(draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="user")
db_mock = mocker.patch(f"{MODULE}.db")
db_mock.session.scalar.side_effect = [draft_row, mocker.MagicMock(query="original question")]
account_user = mocker.MagicMock(spec=Account)
account_user.id = "user"
generator.resume_after_form_submission(
app_model=mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent"),
user=account_user,
conversation_id="conv",
invoke_from=InvokeFrom.DEBUGGER,
)
assert generator._resolve_agent.call_args.kwargs["draft_type"] == "debug_build"
@@ -23,22 +23,35 @@ from dify_agent.protocol import (
RunSucceededEventData,
RuntimeLayerSpec,
)
from pydantic_ai.messages import PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta
from pydantic_ai.messages import (
FunctionToolCallEvent,
FunctionToolResultEvent,
PartDeltaEvent,
PartStartEvent,
TextPart,
TextPartDelta,
ThinkingPartDelta,
ToolCallPart,
ToolReturnPart,
)
from clients.agent_backend import (
AgentBackendError,
AgentBackendRunEventAdapter,
AgentBackendStreamInternalEvent,
FakeAgentBackendRunClient,
FakeAgentBackendScenario,
)
from core.app.apps.agent_app import app_runner as app_runner_module
from core.app.apps.agent_app.app_runner import AgentAppRunner
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent
from core.app.entities.queue_entities import QueueAgentThoughtEvent, QueueLLMChunkEvent, QueueMessageEndEvent
from core.workflow.nodes.agent_v2.ask_human_resume import AskHumanResumeOutcome
from models.agent_config_entities import AgentSoulConfig
from models.model import MessageAgentThought
class _FakeCredentialsProvider:
@@ -54,8 +67,9 @@ def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
class _NoToolsBuilder:
def build(self, **kwargs):
def build_layers(self, **kwargs):
del kwargs
return SimpleNamespace(plugin_tools=None, core_tools=None, exposed_tool_names=lambda: [])
class _FakeQueueManager:
@@ -86,6 +100,24 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
return super().cancel_run(run_id, request=request)
class _BlockingRecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.wait_calls: list[tuple[str, float | None]] = []
self.stream_called = False
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del run_id, after
self.stream_called = True
return iter(())
@override
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None):
self.wait_calls.append((run_id, timeout_seconds))
return super().wait_run(run_id, timeout_seconds=timeout_seconds)
class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
@@ -138,6 +170,65 @@ class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient):
)
class _ProcessStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
id="2-0",
run_id=run_id,
created_at=created_at,
data=PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="I need to inspect the file.")),
)
yield PydanticAIStreamRunEvent(
id="3-0",
run_id=run_id,
created_at=created_at,
data=FunctionToolCallEvent(part=ToolCallPart(tool_name="bash", args={"cmd": "ls"}, tool_call_id="tool-1")),
)
yield PydanticAIStreamRunEvent(
id="4-0",
run_id=run_id,
created_at=created_at,
data=FunctionToolResultEvent(part=ToolReturnPart(tool_name="bash", content="ok", tool_call_id="tool-1")),
)
yield PydanticAIStreamRunEvent(
id="5-0",
run_id=run_id,
created_at=created_at,
data=PartDeltaEvent(index=1, delta=TextPartDelta(content_delta="final answer")),
)
yield RunSucceededEvent(
id="6-0",
run_id=run_id,
created_at=created_at,
data=RunSucceededEventData(
output={"text": "final answer"},
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
class _FakeDbSession:
def __init__(self) -> None:
self.rows: dict[str, MessageAgentThought] = {}
self.rollback_count = 0
def add(self, row: MessageAgentThought) -> None:
self.rows[str(row.id)] = row
def commit(self) -> None:
pass
def get(self, _model: type[MessageAgentThought], row_id: str) -> MessageAgentThought | None:
return self.rows.get(row_id)
def rollback(self) -> None:
self.rollback_count += 1
class _FakeSessionStore:
def __init__(
self,
@@ -212,7 +303,7 @@ def _runner(client: FakeAgentBackendRunClient, store: _FakeSessionStore) -> Agen
return AgentAppRunner(
request_builder=AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
),
agent_backend_client=client,
event_adapter=AgentBackendRunEventAdapter(),
@@ -234,6 +325,18 @@ def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None:
)
def _run_stateless(runner: AgentAppRunner) -> None:
runner.run_stateless(
dify_context=_dify_ctx(),
agent_id="agent-1",
agent_config_snapshot_id="snap-1",
agent_soul=_soul(),
conversation_id="conv-1",
query="finalize",
idempotency_key="run-req-1",
)
def _message_end(qm: _FakeQueueManager) -> QueueMessageEndEvent:
return next(e for e in qm.events if isinstance(e, QueueMessageEndEvent))
@@ -311,6 +414,116 @@ def test_successful_turn_forwards_part_start_text_and_publishes_missing_terminal
assert end_events[0].llm_result.message.content == "hello agent"
def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch):
fake_session = _FakeDbSession()
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
client = _ProcessStreamingFakeAgentBackendRunClient()
store = _FakeSessionStore()
qm = _FakeQueueManager()
_run(_runner(client, store), qm)
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
assert [event.chunk.delta.message.content for event in chunk_events] == ["final answer"]
thought_events = [e for e in qm.events if isinstance(e, QueueAgentThoughtEvent)]
assert len(thought_events) >= 3
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[1].tool == "bash"
assert rows[1].tool_input == '{"cmd": "ls"}'
assert rows[1].observation == "ok"
def test_tool_result_without_identity_does_not_attach_to_previous_tool(monkeypatch):
fake_session = _FakeDbSession()
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
qm = _FakeQueueManager()
recorder = app_runner_module._AgentProcessRecorder(
dify_context=_dify_ctx(),
message_id="msg-1",
queue_manager=qm, # type: ignore[arg-type]
)
recorder.handle_stream_event(
AgentBackendStreamInternalEvent(
run_id="run-1",
data={
"event_kind": "function_tool_call",
"part": {
"part_kind": "tool-call",
"tool_name": "shell_run",
"args": {"script": "npx skills find browser"},
"tool_call_id": "shell-call-1",
},
},
)
)
recorder.handle_stream_event(
AgentBackendStreamInternalEvent(
run_id="run-1",
data={
"event_kind": "function_tool_result",
"content": "Knowledge base search results: browser skill",
},
)
)
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
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[1].observation == "Knowledge base search results: browser skill"
def test_tool_result_without_call_id_matches_unique_open_tool_name(monkeypatch):
fake_session = _FakeDbSession()
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
qm = _FakeQueueManager()
recorder = app_runner_module._AgentProcessRecorder(
dify_context=_dify_ctx(),
message_id="msg-1",
queue_manager=qm, # type: ignore[arg-type]
)
recorder.handle_stream_event(
AgentBackendStreamInternalEvent(
run_id="run-1",
data={
"event_kind": "function_tool_call",
"part": {
"part_kind": "tool-call",
"tool_name": "knowledge_base_search",
"args": {"query": "browser"},
},
},
)
)
recorder.handle_stream_event(
AgentBackendStreamInternalEvent(
run_id="run-1",
data={
"event_kind": "function_tool_result",
"part": {
"part_kind": "tool-return",
"tool_name": "knowledge_base_search",
"content": "Knowledge base search results: browser skill",
},
},
)
)
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
assert len(rows) == 1
assert rows[0].tool == "knowledge_base_search"
assert rows[0].tool_input == '{"query": "browser"}'
assert rows[0].observation == "Knowledge base search results: browser skill"
def test_prior_session_snapshot_is_threaded_into_request():
prior = CompositorSessionSnapshot(layers=[])
client = FakeAgentBackendRunClient()
@@ -348,6 +561,31 @@ def test_debug_session_scope_can_reuse_conversation_across_config_snapshots():
assert store.saved[0][0].agent_config_snapshot_id is None
def test_stateless_run_uses_bounded_wait_and_does_not_save_session_state():
prior = CompositorSessionSnapshot(layers=[])
client = _BlockingRecordingFakeAgentBackendRunClient()
store = _FakeSessionStore(loaded=prior)
_run_stateless(_runner(client, store))
assert client.request is not None
assert client.request.session_snapshot is prior
assert client.wait_calls == [("fake-run-1", app_runner_module.dify_config.APP_MAX_EXECUTION_TIME)]
assert client.stream_called is False
assert store.saved == []
def test_stateless_run_raises_backend_error_on_failed_bounded_wait():
client = _BlockingRecordingFakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED)
store = _FakeSessionStore()
with pytest.raises(AgentBackendError):
_run_stateless(_runner(client, store))
assert client.wait_calls == [("fake-run-1", app_runner_module.dify_config.APP_MAX_EXECUTION_TIME)]
assert store.saved == []
def test_failed_run_raises_agent_backend_error():
client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED)
store = _FakeSessionStore()
@@ -85,7 +85,7 @@ class TestResolveAgent:
_patch_session(monkeypatch, [bound_agent, inner_agent, snapshot])
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
agent, snap, soul = AgentAppGenerator()._resolve_agent(
agent, config_id, config_version_kind, soul = AgentAppGenerator()._resolve_agent(
app_model,
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
@@ -93,7 +93,8 @@ class TestResolveAgent:
) # type: ignore[arg-type]
assert agent is bound_agent
assert snap == snapshot.id
assert config_id == snapshot.id
assert config_version_kind == "snapshot"
assert soul.model is not None
def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch):
@@ -7,9 +7,14 @@ from types import SimpleNamespace
from typing import Any
import pytest
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from clients.agent_backend import (
DIFY_CONFIG_LAYER_ID,
DIFY_CORE_TOOLS_LAYER_ID,
DIFY_PLUGIN_TOOLS_LAYER_ID,
AgentBackendAgentAppRunInput,
AgentBackendModelConfig,
AgentBackendRunRequestBuilder,
@@ -81,54 +86,64 @@ class _FakeCredentialsProvider:
class _NoToolsBuilder:
def build(self, **kwargs):
def build_layers(self, **kwargs):
del kwargs
return SimpleNamespace(plugin_tools=None, core_tools=None, exposed_tool_names=lambda: [])
def _mock_empty_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
class _PluginLayerBuilder:
def build_layers(self, **kwargs):
return SimpleNamespace(
plugin_tools=DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/time",
provider="time",
tool_name="current_time",
credential_type="unauthorized",
name="current_time",
description="Get current time.",
credentials={},
runtime_parameters={},
parameters=[],
parameters_json_schema={"type": "object", "properties": {}, "required": []},
)
]
),
core_tools=None,
exposed_tool_names=lambda: ["current_time"],
)
def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 123,
"mime_type": "text/markdown",
"hash": "hash-1",
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False},
{"key": "files/sample.pdf", "is_skill": False},
],
)
class _CoreLayerBuilder:
def build_layers(self, **kwargs):
del kwargs
return SimpleNamespace(
plugin_tools=None,
core_tools=DifyCoreToolsLayerConfig(
tools=[
DifyCoreToolConfig(
provider_type="builtin",
provider_id="audio",
tool_name="transcribe",
name="transcribe",
description="Transcribe audio.",
runtime_parameters={},
parameters=[],
parameters_json_schema={"type": "object", "properties": {}, "required": []},
)
]
),
exposed_tool_names=lambda: ["transcribe"],
)
@pytest.fixture(autouse=True)
def _mock_default_agent_app_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
_mock_empty_drive_catalog(monkeypatch)
def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuildContext:
def _ctx(
soul: AgentSoulConfig,
*,
query: str = "hello",
agent_config_version_kind: str = "snapshot",
) -> AgentAppRuntimeBuildContext:
dify_context = SimpleNamespace(
tenant_id="tenant-1",
app_id="app-1",
@@ -144,6 +159,7 @@ def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuild
conversation_id="conv-1",
user_query=query,
idempotency_key="msg-1",
agent_config_version_kind=agent_config_version_kind, # type: ignore[arg-type]
)
@@ -164,7 +180,7 @@ class TestAgentAppRuntimeRequestBuilder:
def test_build_maps_soul_to_run_request(self):
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(_soul_with_model()))
@@ -175,8 +191,6 @@ class TestAgentAppRuntimeRequestBuilder:
"agent_soul_prompt",
"agent_app_user_prompt",
"execution_context",
"shell",
"drive",
"history",
"llm",
]
@@ -195,6 +209,68 @@ class TestAgentAppRuntimeRequestBuilder:
assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]"
assert result.metadata["conversation_id"] == "conv-1"
def test_build_includes_plugin_tools_layer_returned_by_injected_builder_for_draft(self):
soul = _soul_with_model()
soul.tools.dify_tools = [
{
"provider_type": "plugin",
"provider_id": "langgenius/time/time",
"tool_name": "current_time",
}
]
tools_builder = _PluginLayerBuilder()
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
dify_tools_builder=tools_builder, # type: ignore[arg-type]
)
result = builder.build(_ctx(soul, agent_config_version_kind="draft"))
names = [layer.name for layer in result.request.composition.layers]
assert DIFY_PLUGIN_TOOLS_LAYER_ID in names
assert DIFY_CORE_TOOLS_LAYER_ID not in names
def test_build_includes_plugin_tools_layer_returned_by_injected_builder_for_snapshot(self):
soul = _soul_with_model()
soul.tools.dify_tools = [
{
"provider_type": "plugin",
"provider_id": "langgenius/time/time",
"tool_name": "current_time",
}
]
tools_builder = _PluginLayerBuilder()
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
dify_tools_builder=tools_builder, # type: ignore[arg-type]
)
result = builder.build(_ctx(soul, agent_config_version_kind="snapshot"))
names = [layer.name for layer in result.request.composition.layers]
assert DIFY_PLUGIN_TOOLS_LAYER_ID in names
assert DIFY_CORE_TOOLS_LAYER_ID not in names
def test_build_includes_core_tools_layer_returned_by_injected_builder(self):
soul = _soul_with_model()
soul.tools.dify_tools = [
{
"provider_type": "builtin",
"provider_id": "audio",
"tool_name": "transcribe",
}
]
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
dify_tools_builder=_CoreLayerBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
names = [layer.name for layer in result.request.composition.layers]
assert DIFY_CORE_TOOLS_LAYER_ID in names
assert DIFY_PLUGIN_TOOLS_LAYER_ID not in names
def test_build_normalizes_marketplace_model_plugin_id(self):
soul = _soul_with_model()
soul.model.plugin_id = (
@@ -202,7 +278,7 @@ class TestAgentAppRuntimeRequestBuilder:
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
@@ -238,7 +314,7 @@ class TestAgentAppRuntimeRequestBuilder:
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
@@ -257,7 +333,7 @@ class TestAgentAppRuntimeRequestBuilder:
def test_build_raises_when_model_missing(self):
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
with pytest.raises(AgentAppRuntimeRequestBuildError) as exc:
builder.build(_ctx(AgentSoulConfig()))
@@ -279,7 +355,7 @@ class TestAgentAppRuntimeRequestBuilder:
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
@@ -298,7 +374,7 @@ class TestAgentAppRuntimeRequestBuilder:
}
# ── ENG-623: drive declaration on the Agent App surface ──────────────────────
# ── Agent config layer declaration on the Agent App surface ──────────────────
def _soul_with_model_and_skill() -> AgentSoulConfig:
@@ -309,143 +385,138 @@ def _soul_with_model_and_skill() -> AgentSoulConfig:
"model_provider": "langgenius/openai/openai",
"model": "gpt-4o-mini",
},
"prompt": {"system_prompt": "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"},
"files": {
"skills": [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
}
]
},
"prompt": {"system_prompt": "Use [§skill:tender-analyzer:Tender Analyzer§]"},
"config_skills": [{"name": "tender-analyzer", "description": "Parses RFPs.", "file_id": "tool-file-1"}],
"config_files": [{"name": "sample.pdf", "file_kind": "upload_file", "file_id": "upload-file-1"}],
"config_note": "Read the proposal first.",
}
)
class TestAgentAppDriveLayer:
def test_drive_layer_injected_when_flag_enabled(self, monkeypatch: pytest.MonkeyPatch):
class TestAgentAppConfigLayer:
def test_config_layer_injected_when_flag_enabled(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
_mock_drive_catalog(monkeypatch)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(_soul_with_model_and_skill()))
drive = next(layer for layer in result.request.composition.layers if layer.name == "drive")
assert drive.type == "dify.drive"
assert drive.deps == {"shell": DIFY_SHELL_LAYER_ID}
assert drive.config.drive_ref == "agent-agent-1"
assert [skill.skill_md_key for skill in drive.config.skills] == ["tender-analyzer/SKILL.md"]
assert drive.config.mentioned_skill_keys == ["tender-analyzer/SKILL.md"]
# shell enters first; drive uses that shell to materialize mentioned targets.
config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
assert config.type == "dify.config"
assert config.deps == {"shell": DIFY_SHELL_LAYER_ID}
assert config.config.agent_id == "agent-1"
assert config.config.config_version is not None
assert config.config.config_version.id == "snap-1"
assert config.config.config_version.kind == "snapshot"
assert config.config.config_version.writable is False
assert [skill.name for skill in config.config.skills] == ["tender-analyzer"]
assert [file_ref.name for file_ref in config.config.files] == ["sample.pdf"]
assert config.config.note == "Read the proposal first."
assert config.config.mentioned_skill_names == ["tender-analyzer"]
assert config.config.mentioned_file_names == []
# shell enters first; config uses that shell to materialize mentioned targets.
names = [layer.name for layer in result.request.composition.layers]
assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 1
assert names.index("drive") == names.index(DIFY_SHELL_LAYER_ID) + 1
assert names.index(DIFY_CONFIG_LAYER_ID) == names.index(DIFY_SHELL_LAYER_ID) + 1
def test_drive_layer_injected_with_empty_catalog_and_drive_depends_on_shell(self, monkeypatch: pytest.MonkeyPatch):
def test_no_config_layer_when_agent_soul_has_no_config_assets(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(_soul_with_model()))
layers = {layer.name: layer for layer in result.request.composition.layers}
assert layers["drive"].config.drive_ref == "agent-agent-1"
assert layers["drive"].config.skills == []
assert DIFY_CONFIG_LAYER_ID not in layers
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": "execution_context"}
assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref == "agent-agent-1"
assert layers["drive"].deps == {"shell": DIFY_SHELL_LAYER_ID}
assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref is None
def test_no_drive_layer_when_flag_disabled(self, monkeypatch: pytest.MonkeyPatch):
def test_config_layer_for_build_draft_marks_config_writable(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(_soul_with_model_and_skill(), agent_config_version_kind="build_draft"))
config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
assert config.config.model_dump(mode="json") == {
"agent_id": "agent-1",
"config_version": {"id": "snap-1", "kind": "build_draft", "writable": True},
"skills": [
{
"name": "tender-analyzer",
"description": "Parses RFPs.",
"size": None,
"mime_type": "application/zip",
}
],
"files": [{"name": "sample.pdf", "size": None, "mime_type": None}],
"env_keys": [],
"note": "Read the proposal first.",
"mentioned_skill_names": ["tender-analyzer"],
"mentioned_file_names": [],
}
def test_no_config_layer_when_flag_disabled(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(_soul_with_model_and_skill()))
assert all(layer.name != "drive" for layer in result.request.composition.layers)
assert all(layer.name != DIFY_CONFIG_LAYER_ID for layer in result.request.composition.layers)
def test_agent_app_runtime_expands_skill_and_file_mentions_in_agent_soul_prompt(
@pytest.mark.parametrize(
("system_prompt", "expected_prefix"),
[
(
"Use [§skill:tender-analyzer:Tender Analyzer§] and [§file:sample.pdf:sample.pdf§].",
"Use tender-analyzer and sample.pdf.",
),
(
"Use [§skill:tender-analyzer:Tender Analyzer§] and [§file:sample.pdf:sample.pdf§]",
"Use tender-analyzer and sample.pdf",
),
],
)
def test_agent_app_runtime_expands_config_mentions_in_agent_soul_prompt(
self,
monkeypatch: pytest.MonkeyPatch,
system_prompt: str,
expected_prefix: str,
):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
_mock_drive_catalog(monkeypatch)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
)
soul = _soul_with_model_and_skill()
soul.prompt.system_prompt = system_prompt
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use Tender Analyzer and sample.pdf."
assert prompt_layer.config.prefix == expected_prefix
assert "[§" not in prompt_layer.config.prefix
def test_agent_app_runtime_missing_drive_mentions_fall_back_to_label_then_decoded_key(
self,
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
_mock_drive_catalog(monkeypatch)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
"and [§file:files%2Fmissing.txt§]."
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and files/missing.txt."
assert "[§" not in prompt_layer.config.prefix
def test_agent_app_runtime_expands_drive_mentions_in_agent_soul_prompt(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
_mock_drive_catalog(monkeypatch)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]"
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use Tender Analyzer and sample.pdf"
assert "[§" not in prompt_layer.config.prefix
def test_agent_app_runtime_missing_drive_mentions_fall_back_without_marker_leak(
def test_agent_app_runtime_missing_config_mentions_fall_back_without_marker_leak(
self,
monkeypatch: pytest.MonkeyPatch,
):
@@ -454,16 +525,15 @@ class TestAgentAppDriveLayer:
)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
"and [§file:files%2Fno-label.txt§]."
"Use [§skill:ghost-skill:Ghost Skill§], [§file:ghost.txt:Ghost File§], and [§file:no-label.txt§]."
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and files/no-label.txt."
assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and no-label.txt."
assert "[§" not in prompt_layer.config.prefix
@@ -88,7 +88,7 @@ def test_convert_tool_response_to_str_and_extract_binary_messages():
tool.create_json_message({"a": 1}),
tool.create_json_message({"a": 1}, suppress_output=True),
]
text = ToolEngine._convert_tool_response_to_str(messages)
text = ToolEngine.tool_response_to_str(messages)
assert "hello" in text
assert "result link: https://example.com." in text
assert '"a": 1' in text
@@ -271,7 +271,7 @@ def test_convert_tool_response_excludes_variable_messages():
"""Regression test for issue #34723.
WorkflowTool._invoke yields VARIABLE, TEXT, and suppressed-JSON messages.
_convert_tool_response_to_str must skip VARIABLE messages so that the
tool_response_to_str must skip VARIABLE messages so that the
returned string contains only the TEXT representation and not a
duplicated, garbled Pydantic repr of the same data.
"""
@@ -283,7 +283,7 @@ def test_convert_tool_response_excludes_variable_messages():
tool.create_json_message(outputs, suppress_output=True),
]
result = ToolEngine._convert_tool_response_to_str(messages)
result = ToolEngine.tool_response_to_str(messages)
assert result == '{"reports": "hello"}'
assert "variable_name" not in result
@@ -16,11 +16,12 @@ from core.tools.entities.tool_entities import (
ToolIdentity,
ToolInvokeMessage,
ToolParameter,
ToolProviderType,
)
from core.tools.tool_manager import ToolManager
from core.workflow.nodes.agent_v2.plugin_tools_builder import (
WorkflowAgentPluginToolsBuilder,
WorkflowAgentPluginToolsBuildError,
from core.workflow.nodes.agent_v2.dify_tools_builder import (
WorkflowAgentDifyToolsBuilder,
WorkflowAgentDifyToolsBuildError,
)
from models.agent_config_entities import AgentSoulToolsConfig
@@ -29,8 +30,9 @@ class FakeRuntimeProvider:
def __init__(self, tool: Tool | Exception) -> None:
# Either a Tool to hand back, or an exception to raise on lookup. The
# latter lets tests exercise the error-mapping branches in
# ``WorkflowAgentPluginToolsBuilder._fetch_tool_runtime``.
# ``WorkflowAgentDifyToolsBuilder._fetch_tool_runtime``.
self.tool = tool
self.call_count = 0
self.last_agent_tool: AgentToolEntity | None = None
self.last_invoke_from: InvokeFrom | None = None
self.last_allow_file_parameters: bool | None = None
@@ -47,6 +49,7 @@ class FakeRuntimeProvider:
allow_file_parameters: bool = False,
use_default_for_missing_form_parameters: bool = False,
) -> Tool:
self.call_count += 1
self.last_agent_tool = agent_tool
self.last_invoke_from = invoke_from
self.last_allow_file_parameters = allow_file_parameters
@@ -182,25 +185,26 @@ def _tts_tool() -> FakeTool:
def _build(
builder: WorkflowAgentPluginToolsBuilder,
builder: WorkflowAgentDifyToolsBuilder,
tools: AgentSoulToolsConfig,
*,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
):
"""Shorthand for ``builder.build(...)`` with the standard tenant/app/user
triple, so each test only highlights what's actually unique to it."""
return builder.build(
"""Shorthand for tests that expect ``build_layers(...)`` to return only plugin tools."""
layers = builder.build_layers(
tenant_id="tenant-1",
app_id="app-1",
user_id="user-1",
tools=tools,
invoke_from=invoke_from,
)
assert layers.core_tools is None
return layers.plugin_tools
def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime():
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
@@ -235,9 +239,9 @@ def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime():
assert runtime_provider.last_agent_tool.provider_type.value == "plugin"
def test_builds_dify_plugin_tool_with_file_llm_parameter():
def test_builds_core_tool_with_file_llm_parameter():
runtime_provider = FakeRuntimeProvider(_file_tool())
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
@@ -251,24 +255,192 @@ def test_builds_dify_plugin_tool_with_file_llm_parameter():
}
)
result = _build(builder, tools)
result = builder.build_layers(
tenant_id="tenant-1",
app_id="app-1",
user_id="user-1",
tools=tools,
invoke_from=InvokeFrom.DEBUGGER,
)
assert result is not None
prepared = result.tools[0]
assert result.core_tools is not None
prepared = result.core_tools.tools[0]
assert prepared.tool_name == "asr"
assert prepared.runtime_parameters == {}
assert prepared.parameters[0].name == "audio_file"
assert prepared.parameters[0].type == "file"
# The public Agent backend DTO carries non-scalar tool inputs in
# ``parameters``; legacy JSON schema generation omits file fields.
assert prepared.parameters_json_schema == {"type": "object", "properties": {}, "required": []}
assert runtime_provider.last_allow_file_parameters is True
assert runtime_provider.last_use_default_for_missing_form_parameters is True
def test_builds_dify_plugin_tool_with_missing_required_select_default():
def test_build_layers_routes_plugin_direct_and_builtin_via_core() -> None:
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_id": "langgenius/search/search",
"provider_type": "plugin",
"tool_name": "search",
"credential_type": "api-key",
"credential_id": "credential-1",
"runtime_parameters": {"region": "us"},
},
{
"provider_id": "audio",
"provider_type": "builtin",
"tool_name": "transcribe",
"credential_type": "unauthorized",
},
]
}
)
result = builder.build_layers(
tenant_id="tenant-1",
app_id="app-1",
user_id="user-1",
tools=tools,
invoke_from=InvokeFrom.DEBUGGER,
)
assert result.plugin_tools is not None
assert [tool.tool_name for tool in result.plugin_tools.tools] == ["search"]
assert result.core_tools is not None
assert [tool.provider_type for tool in result.core_tools.tools] == ["builtin"]
assert [tool.tool_name for tool in result.core_tools.tools] == ["transcribe"]
@pytest.mark.parametrize(
("provider_type", "provider_id"),
[
("api", "search-api"),
("workflow", "workflow-provider-1"),
],
)
def test_build_layers_routes_api_and_workflow_via_core(provider_type: str, provider_id: str) -> None:
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_id": provider_id,
"provider_type": provider_type,
"tool_name": "search",
"credential_type": "unauthorized",
}
]
}
)
result = builder.build_layers(
tenant_id="tenant-1",
app_id="app-1",
user_id="user-1",
tools=tools,
invoke_from=InvokeFrom.DEBUGGER,
)
assert result.plugin_tools is None
assert result.core_tools is not None
assert [tool.provider_type for tool in result.core_tools.tools] == [provider_type]
assert [tool.provider_id for tool in result.core_tools.tools] == [provider_id]
assert [tool.tool_name for tool in result.core_tools.tools] == ["search"]
def test_build_layers_normalizes_mcp_provider_ids_to_server_identifier() -> None:
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentDifyToolsBuilder(
tool_runtime_provider=runtime_provider,
mcp_provider_id_resolver=lambda *, tenant_id, provider_id: "mcp-server-1",
)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_id": "db-row-id",
"provider_type": "mcp",
"tool_name": "search",
"credential_type": "unauthorized",
}
]
}
)
result = builder.build_layers(
tenant_id="tenant-1",
app_id="app-1",
user_id="user-1",
tools=tools,
invoke_from=InvokeFrom.DEBUGGER,
)
assert result.core_tools is not None
assert result.core_tools.tools[0].provider_id == "mcp-server-1"
def test_build_layers_rejects_app_provider_type() -> None:
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_id": "app-provider",
"provider_type": "app",
"tool_name": "search",
"credential_type": "unauthorized",
}
]
}
)
with pytest.raises(WorkflowAgentDifyToolsBuildError, match="not supported"):
builder.build_layers(
tenant_id="tenant-1",
app_id="app-1",
user_id="user-1",
tools=tools,
invoke_from=InvokeFrom.DEBUGGER,
)
assert runtime_provider.last_agent_tool is None
def test_build_layers_rejects_dataset_retrieval_provider_type() -> None:
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_id": "dataset-provider",
"provider_type": "dataset-retrieval",
"tool_name": "search",
"credential_type": "unauthorized",
}
]
}
)
with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info:
builder.build_layers(
tenant_id="tenant-1",
app_id="app-1",
user_id="user-1",
tools=tools,
invoke_from=InvokeFrom.DEBUGGER,
)
assert exc_info.value.error_code == "agent_tool_provider_not_supported"
assert runtime_provider.last_agent_tool is None
def test_builds_core_tool_with_missing_required_select_default():
runtime_provider = FakeRuntimeProvider(_tts_tool())
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
@@ -282,17 +454,24 @@ def test_builds_dify_plugin_tool_with_missing_required_select_default():
}
)
result = _build(builder, tools)
result = builder.build_layers(
tenant_id="tenant-1",
app_id="app-1",
user_id="user-1",
tools=tools,
invoke_from=InvokeFrom.DEBUGGER,
)
assert result is not None
prepared = result.tools[0]
assert result.core_tools is not None
prepared = result.core_tools.tools[0]
assert prepared.tool_name == "tts"
assert prepared.runtime_parameters == {"model": "provider-a#model-a"}
assert runtime_provider.last_use_default_for_missing_form_parameters is True
def test_rejects_duplicate_exposed_tool_names():
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool()))
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
@@ -314,14 +493,15 @@ def test_rejects_duplicate_exposed_tool_names():
}
)
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info:
_build(builder, tools)
assert exc_info.value.error_code == "agent_tool_name_duplicated"
assert runtime_provider.call_count == 1
def test_rejects_missing_required_runtime_parameter():
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool(runtime_parameters={})))
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool(runtime_parameters={})))
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
@@ -335,7 +515,7 @@ def test_rejects_missing_required_runtime_parameter():
}
)
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info:
_build(builder, tools)
assert exc_info.value.error_code == "agent_tool_runtime_parameter_missing"
@@ -355,7 +535,7 @@ def test_invoke_from_is_forwarded_to_tool_runtime_provider():
representative invoke_from values."""
for invoke_from in (InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP):
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
@@ -382,7 +562,7 @@ def test_invoke_from_is_forwarded_to_tool_runtime_provider():
def test_disabled_tools_are_skipped():
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
@@ -408,7 +588,7 @@ def test_plugin_id_plus_provider_fallback_when_provider_id_missing():
"""Frontend may send ``plugin_id`` + ``provider`` instead of the
concatenated ``provider_id``; the builder must accept both shapes."""
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
@@ -444,7 +624,7 @@ def test_unauthorized_tool_without_credentials():
return tool
runtime_provider = FakeRuntimeProvider(_no_credentials_tool())
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
@@ -488,10 +668,10 @@ def _standard_tools_payload() -> AgentSoulToolsConfig:
def test_tool_provider_not_found_maps_to_declaration_not_found():
from core.tools.errors import ToolProviderNotFoundError
builder = WorkflowAgentPluginToolsBuilder(
builder = WorkflowAgentDifyToolsBuilder(
tool_runtime_provider=FakeRuntimeProvider(ToolProviderNotFoundError("provider gone"))
)
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info:
_build(builder, _standard_tools_payload())
assert exc_info.value.error_code == "agent_tool_declaration_not_found"
@@ -499,10 +679,10 @@ def test_tool_provider_not_found_maps_to_declaration_not_found():
def test_credential_validation_error_maps_to_credential_invalid():
from core.tools.errors import ToolProviderCredentialValidationError
builder = WorkflowAgentPluginToolsBuilder(
builder = WorkflowAgentDifyToolsBuilder(
tool_runtime_provider=FakeRuntimeProvider(ToolProviderCredentialValidationError("creds expired"))
)
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info:
_build(builder, _standard_tools_payload())
assert exc_info.value.error_code == "agent_tool_credential_invalid"
@@ -512,8 +692,8 @@ def test_generic_value_error_maps_to_config_invalid():
``agent_tool_config_invalid`` — distinct from
``agent_tool_declaration_not_found`` so callers can render a different
hint."""
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(ValueError("runtime missing")))
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(ValueError("runtime missing")))
with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info:
_build(builder, _standard_tools_payload())
assert exc_info.value.error_code == "agent_tool_config_invalid"
@@ -536,8 +716,8 @@ def test_rejects_non_scalar_credential_value():
tool.runtime.credentials = {"oauth": {"access_token": "secret", "expires_in": 3600}}
return tool
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_dict_credential_tool()))
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_dict_credential_tool()))
with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info:
_build(builder, _standard_tools_payload())
assert exc_info.value.error_code == "agent_tool_credential_shape_invalid"
@@ -578,13 +758,13 @@ def test_legacy_provider_name_and_tool_parameters_normalized():
def test_provider_level_entry_expands_to_all_tools():
runtime_provider = FakeRuntimeProvider(_tool())
listed: list[tuple[str, str]] = []
listed: list[tuple[str, str, ToolProviderType]] = []
def lister(*, tenant_id: str, provider_id: str) -> list[str]:
listed.append((tenant_id, provider_id))
def lister(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str) -> list[str]:
listed.append((tenant_id, provider_id, provider_type))
return ["search", "image_search"]
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider, provider_tools_lister=lister)
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider, provider_tools_lister=lister)
tools = AgentSoulToolsConfig.model_validate(
{"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]}
)
@@ -593,13 +773,13 @@ def test_provider_level_entry_expands_to_all_tools():
assert result is not None
assert [tool.tool_name for tool in result.tools] == ["search", "image_search"]
assert listed == [("tenant-1", "langgenius/search/search")]
assert listed == [("tenant-1", "langgenius/search/search", ToolProviderType.PLUGIN)]
def test_explicit_tool_entry_wins_over_provider_expansion():
builder = WorkflowAgentPluginToolsBuilder(
builder = WorkflowAgentDifyToolsBuilder(
tool_runtime_provider=FakeRuntimeProvider(_tool()),
provider_tools_lister=lambda *, tenant_id, provider_id: ["search", "image_search"],
provider_tools_lister=lambda *, tenant_id, provider_type, provider_id: ["search", "image_search"],
)
tools = AgentSoulToolsConfig.model_validate(
{
@@ -623,15 +803,15 @@ def test_explicit_tool_entry_wins_over_provider_expansion():
def test_provider_level_entry_with_no_tools_maps_to_declaration_not_found():
builder = WorkflowAgentPluginToolsBuilder(
builder = WorkflowAgentDifyToolsBuilder(
tool_runtime_provider=FakeRuntimeProvider(_tool()),
provider_tools_lister=lambda *, tenant_id, provider_id: [],
provider_tools_lister=lambda *, tenant_id, provider_type, provider_id: [],
)
tools = AgentSoulToolsConfig.model_validate(
{"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]}
)
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info:
_build(builder, tools)
assert exc_info.value.error_code == "agent_tool_declaration_not_found"
@@ -639,17 +819,17 @@ def test_provider_level_entry_with_no_tools_maps_to_declaration_not_found():
def test_provider_level_entry_unknown_provider_maps_to_declaration_not_found():
from core.tools.errors import ToolProviderNotFoundError
def lister(*, tenant_id: str, provider_id: str) -> list[str]:
def lister(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str) -> list[str]:
raise ToolProviderNotFoundError("provider gone")
builder = WorkflowAgentPluginToolsBuilder(
builder = WorkflowAgentDifyToolsBuilder(
tool_runtime_provider=FakeRuntimeProvider(_tool()), provider_tools_lister=lister
)
tools = AgentSoulToolsConfig.model_validate(
{"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]}
)
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info:
_build(builder, tools)
assert exc_info.value.error_code == "agent_tool_declaration_not_found"
@@ -659,7 +839,7 @@ def test_list_provider_tool_names_reads_builtin_provider(monkeypatch):
to the plain name list the expansion step consumes."""
from types import SimpleNamespace
from core.workflow.nodes.agent_v2 import plugin_tools_builder as module
from core.workflow.nodes.agent_v2 import dify_tools_builder as module
provider = SimpleNamespace(
get_tools=lambda: [
@@ -676,7 +856,11 @@ def test_list_provider_tool_names_reads_builtin_provider(monkeypatch):
monkeypatch.setattr(module.ToolManager, "get_builtin_provider", staticmethod(fake_get_builtin_provider))
names = module._list_provider_tool_names(tenant_id="tenant-1", provider_id="langgenius/duckduckgo/duckduckgo")
names = module._list_provider_tool_names(
tenant_id="tenant-1",
provider_type=module.ToolProviderType.BUILT_IN,
provider_id="langgenius/duckduckgo/duckduckgo",
)
assert names == ["ddg_search", "ddg_news"]
assert captured == {"provider_id": "langgenius/duckduckgo/duckduckgo", "tenant_id": "tenant-1"}
@@ -1,17 +1,24 @@
import json
from dataclasses import replace
from types import SimpleNamespace
from typing import cast
import pytest
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID
from clients.agent_backend import DIFY_EXECUTION_CONTEXT_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID
from clients.agent_backend import (
DIFY_CONFIG_LAYER_ID,
DIFY_CORE_TOOLS_LAYER_ID,
DIFY_EXECUTION_CONTEXT_LAYER_ID,
DIFY_PLUGIN_TOOLS_LAYER_ID,
)
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom
from core.workflow.file_reference import build_file_reference
from core.workflow.nodes.agent_v2.plugin_tools_builder import WorkflowAgentPluginToolsBuilder
from core.workflow.nodes.agent_v2.dify_tools_builder import WorkflowAgentDifyToolsBuilder
from core.workflow.nodes.agent_v2.runtime_request_builder import (
WorkflowAgentRuntimeBuildContext,
WorkflowAgentRuntimeRequestBuilder,
@@ -24,6 +31,7 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentSoulConfig,
AgentSoulModelConfig,
AgentSoulToolsConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
@@ -79,35 +87,65 @@ def test_agent_soul_round_trip_preserves_existing_app_feature_fields():
assert dumped["app_features"]["more_like_this"] == {"enabled": False}
class FakePluginToolsBuilder:
class CapturingPluginLayerBuilder:
def __init__(self) -> None:
# Capture the runtime invocation source so tests can assert it was
# threaded through from ``DifyRunContext.invoke_from`` rather than
# hard-coded to a placeholder like ``VALIDATION``.
self.last_invoke_from: InvokeFrom | None = None
def build(self, *, tenant_id, app_id, user_id, tools, invoke_from):
def build_layers(self, *, tenant_id, app_id, user_id, tools, invoke_from):
assert tenant_id == "tenant-1"
assert app_id == "app-1"
assert user_id == "user-1"
self.last_invoke_from = invoke_from
if not tools.dify_tools:
return None
return DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/time",
provider="time",
tool_name="current_time",
credential_type="unauthorized",
name="current_time",
description="Get current time.",
credentials={},
runtime_parameters={},
parameters=[],
parameters_json_schema={"type": "object", "properties": {}, "required": []},
)
]
return SimpleNamespace(plugin_tools=None, core_tools=None, exposed_tool_names=lambda: [])
return SimpleNamespace(
plugin_tools=DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/time",
provider="time",
tool_name="current_time",
credential_type="unauthorized",
name="current_time",
description="Get current time.",
credentials={},
runtime_parameters={},
parameters=[],
parameters_json_schema={"type": "object", "properties": {}, "required": []},
)
]
),
core_tools=None,
exposed_tool_names=lambda: ["current_time"],
)
class FakeCoreLayerBuilder:
def build_layers(self, *, tenant_id, app_id, user_id, tools, invoke_from):
assert tenant_id == "tenant-1"
assert app_id == "app-1"
assert user_id == "user-1"
del tools, invoke_from
return SimpleNamespace(
plugin_tools=None,
core_tools=DifyCoreToolsLayerConfig(
tools=[
DifyCoreToolConfig(
provider_type="builtin",
provider_id="audio",
tool_name="transcribe",
name="transcribe",
description="Transcribe audio.",
runtime_parameters={},
parameters=[],
parameters_json_schema={"type": "object", "properties": {}, "required": []},
)
]
),
exposed_tool_names=lambda: ["transcribe"],
)
@@ -218,6 +256,89 @@ def test_builds_create_run_request_from_agent_soul_and_node_job():
assert redacted_layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["credentials"] == "[REDACTED]"
def test_build_includes_plugin_tools_layer_returned_by_injected_builder_for_debugger():
tools_builder = CapturingPluginLayerBuilder()
context = _context()
context.snapshot.config_snapshot.tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_type": "plugin",
"provider_id": "langgenius/time/time",
"tool_name": "current_time",
"credential_type": "unauthorized",
}
]
}
)
result = WorkflowAgentRuntimeRequestBuilder(
credentials_provider=FakeCredentialsProvider(),
dify_tools_builder=tools_builder, # type: ignore[arg-type]
).build(context)
layers = _request_layers(result)
assert DIFY_PLUGIN_TOOLS_LAYER_ID in layers
assert DIFY_CORE_TOOLS_LAYER_ID not in layers
assert tools_builder.last_invoke_from == InvokeFrom.DEBUGGER
def test_build_forwards_service_api_invoke_from_to_injected_plugin_layer_builder():
tools_builder = CapturingPluginLayerBuilder()
context = _context()
context.snapshot.config_snapshot.tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_type": "plugin",
"provider_id": "langgenius/time/time",
"tool_name": "current_time",
"credential_type": "unauthorized",
}
]
}
)
context = replace(
context,
dify_context=context.dify_context.model_copy(update={"invoke_from": InvokeFrom.SERVICE_API}),
)
result = WorkflowAgentRuntimeRequestBuilder(
credentials_provider=FakeCredentialsProvider(),
dify_tools_builder=tools_builder, # type: ignore[arg-type]
).build(context)
layers = _request_layers(result)
assert DIFY_PLUGIN_TOOLS_LAYER_ID in layers
assert DIFY_CORE_TOOLS_LAYER_ID not in layers
assert tools_builder.last_invoke_from == InvokeFrom.SERVICE_API
def test_build_includes_core_tools_layer_returned_by_injected_builder():
context = _context()
context.snapshot.config_snapshot.tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_type": "builtin",
"provider_id": "audio",
"tool_name": "transcribe",
"credential_type": "unauthorized",
}
]
}
)
result = WorkflowAgentRuntimeRequestBuilder(
credentials_provider=FakeCredentialsProvider(),
dify_tools_builder=FakeCoreLayerBuilder(), # type: ignore[arg-type]
).build(context)
layers = _request_layers(result)
assert DIFY_CORE_TOOLS_LAYER_ID in layers
assert DIFY_PLUGIN_TOOLS_LAYER_ID not in layers
def test_normalizes_langgenius_model_provider_for_agent_backend_transport():
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
@@ -287,13 +408,25 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "service-api"
assert dumped["idempotency_key"] == "node-exec-1"
output_schema = dumped["composition"]["layers"][-1]["config"]["json_schema"]
output_description = dumped["composition"]["layers"][-1]["config"]["description"]
report_schema = output_schema["properties"]["report"]
assert len(report_schema["oneOf"]) == 4
assert all(branch["additionalProperties"] is False for branch in report_schema["oneOf"])
assert report_schema["oneOf"][0]["required"] == ["transfer_method", "reference"]
assert report_schema["oneOf"][1]["required"] == ["transfer_method", "reference"]
assert report_schema["oneOf"][2]["required"] == ["transfer_method", "reference"]
assert report_schema["oneOf"][3]["required"] == ["transfer_method", "url"]
report_schema_branches = {
branch["properties"]["transfer_method"]["enum"][0]: branch for branch in report_schema["anyOf"]
}
assert set(report_schema_branches) == {"tool_file", "remote_url"}
tool_file_branch = report_schema_branches["tool_file"]
assert tool_file_branch["additionalProperties"] is False
assert tool_file_branch["required"] == ["transfer_method", "reference"]
assert set(tool_file_branch["properties"]) == {"transfer_method", "reference"}
assert tool_file_branch["properties"]["reference"]["pattern"].startswith("^dify-file-ref:")
remote_url_branch = report_schema_branches["remote_url"]
assert remote_url_branch["additionalProperties"] is False
assert remote_url_branch["required"] == ["transfer_method", "url"]
assert set(remote_url_branch["properties"]) == {"transfer_method", "url"}
assert "dify-agent file upload <path>" in output_description
assert "final_output.report" in output_description
assert "never invent the `reference` value" in output_description
assert "Do not call `final_output` before the upload command succeeds" in output_description
assert output_schema["properties"]["confidence"]["type"] == "number"
assert output_schema["required"] == ["report"]
assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_settings"] == {"temperature": 0.2}
@@ -507,10 +640,10 @@ def test_builds_workflow_run_request_with_dify_plugin_tools_layer():
)
context = replace(context, snapshot=snapshot)
plugin_tools_builder = FakePluginToolsBuilder()
dify_tools_builder = CapturingPluginLayerBuilder()
result = WorkflowAgentRuntimeRequestBuilder(
credentials_provider=FakeCredentialsProvider(),
plugin_tools_builder=cast(WorkflowAgentPluginToolsBuilder, plugin_tools_builder),
dify_tools_builder=cast(WorkflowAgentDifyToolsBuilder, dify_tools_builder),
).build(context)
dumped = result.request.model_dump(mode="json")
@@ -527,7 +660,7 @@ def test_builds_workflow_run_request_with_dify_plugin_tools_layer():
# into the plugin tools builder so ToolManager attributes credential
# quotas / rate limits / audit tags to the real call site instead of a
# hard-coded ``VALIDATION`` placeholder.
assert plugin_tools_builder.last_invoke_from == context.dify_context.invoke_from
assert dify_tools_builder.last_invoke_from == context.dify_context.invoke_from
def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
@@ -840,8 +973,12 @@ def test_empty_declared_outputs_injects_prd_defaults_text_files_json():
assert properties["text"]["type"] == "string"
assert properties["files"]["type"] == "array"
# `files` defaults to array<file> → items is a file ref object.
assert len(properties["files"]["items"]["oneOf"]) == 4
assert all(branch["additionalProperties"] is False for branch in properties["files"]["items"]["oneOf"])
file_item_branches = properties["files"]["items"]["anyOf"]
assert [branch["properties"]["transfer_method"]["enum"] for branch in file_item_branches] == [
["tool_file"],
["remote_url"],
]
assert all(branch["additionalProperties"] is False for branch in file_item_branches)
assert properties["json"]["type"] == "object"
# Defaults are all required=False so no `required:` key on the schema.
assert "required" not in output_layer["json_schema"]
@@ -1123,196 +1260,141 @@ def test_previous_node_remote_url_file_mapping_is_not_truncated_in_workflow_cont
assert "...[truncated]" not in _workflow_user_prompt(result)
# ── ENG-623: dify.drive declaration layer ─────────────────────────────────────
# ── Agent config declaration layer ────────────────────────────────────────────
def _soul_with_drive_skill() -> AgentSoulConfig:
def _soul_with_config_assets() -> AgentSoulConfig:
return AgentSoulConfig(
prompt={
"system_prompt": (
"You are careful. Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] "
"and [§file:files%2Fsample.pdf:sample.pdf§]."
"You are careful. Use [§skill:tender-analyzer:Tender Analyzer§] and [§file:sample.pdf:sample.pdf§]."
)
},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
config_skills=[{"name": "tender-analyzer", "description": "Parses RFPs.", "file_id": "tool-file-1"}],
config_files=[{"name": "sample.pdf", "file_kind": "upload_file", "file_id": "upload-file-1"}],
config_note="Read the proposal first.",
)
def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 123,
"mime_type": "text/markdown",
"hash": "hash-1",
"created_at": 1,
}
],
def test_build_config_layer_config_includes_soul_context_and_mentions():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
config, warnings = build_config_layer_config(
_soul_with_config_assets(),
agent_id="agent-1",
config_version_id="snapshot-1",
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False},
{"key": "files/sample.pdf", "is_skill": False},
],
)
def _mock_empty_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
def test_build_drive_layer_config_catalogs_drive_skills_and_mentions(monkeypatch: pytest.MonkeyPatch):
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
_mock_drive_catalog(monkeypatch)
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), tenant_id="tenant-1", agent_id="agent-1")
assert config is not None
assert config.drive_ref == "agent-agent-1"
assert [skill.skill_md_key for skill in config.skills] == ["tender-analyzer/SKILL.md"]
assert config.skills[0].archive_key == "tender-analyzer/.DIFY-SKILL-FULL.zip"
assert config.mentioned_skill_keys == ["tender-analyzer/SKILL.md"]
assert config.mentioned_file_keys == ["files/sample.pdf"]
assert config.agent_id == "agent-1"
assert config.config_version is not None
assert config.config_version.id == "snapshot-1"
assert config.config_version.kind == "snapshot"
assert config.config_version.writable is False
assert [skill.name for skill in config.skills] == ["tender-analyzer"]
assert [file_ref.name for file_ref in config.files] == ["sample.pdf"]
assert config.note == "Read the proposal first."
assert config.mentioned_skill_names == ["tender-analyzer"]
assert config.mentioned_file_names == ["sample.pdf"]
assert warnings == []
def test_build_drive_layer_config_emits_drive_ref_when_catalog_is_empty(monkeypatch: pytest.MonkeyPatch):
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
def test_build_config_layer_config_returns_none_for_empty_agent_soul():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
_mock_empty_drive_catalog(monkeypatch)
soul = AgentSoulConfig(
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test")
)
config, warnings = build_drive_layer_config(soul, tenant_id="tenant-1", agent_id="agent-1")
config, warnings = build_config_layer_config(soul)
assert config is not None
assert config.drive_ref == "agent-agent-1"
assert config.skills == []
assert config.mentioned_skill_keys == []
assert config.mentioned_file_keys == []
assert config is None
assert warnings == []
def test_workflow_run_request_contains_drive_layer_with_empty_catalog(monkeypatch: pytest.MonkeyPatch):
def test_workflow_run_request_has_no_config_layer_with_empty_agent_soul(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True)
_mock_empty_drive_catalog(monkeypatch)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context())
dumped = result.request.model_dump(mode="json")
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
assert layers["drive"]["config"] == {
"drive_ref": "agent-agent-1",
"skills": [],
"mentioned_skill_keys": [],
"mentioned_file_keys": [],
}
assert DIFY_CONFIG_LAYER_ID not in layers
assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] == "agent-agent-1"
assert layers["drive"]["deps"] == {"shell": DIFY_SHELL_LAYER_ID}
assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] is None
def test_build_drive_layer_config_requires_agent_identity():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), tenant_id="tenant-1", agent_id=None)
assert config is None
assert [w["code"] for w in warnings] == ["drive_ref_dangling"]
def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch):
"""Contract test: locks the dify.drive composition shape against cross-package drift."""
def test_workflow_run_request_contains_config_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch):
"""Contract test: locks the dify.config composition shape against cross-package drift."""
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
_mock_drive_catalog(monkeypatch)
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()
context.snapshot.config_snapshot = _soul_with_config_assets()
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
dumped = result.request.model_dump(mode="json")
layer_names = [layer["name"] for layer in dumped["composition"]["layers"]]
assert "drive" in layer_names
# shell enters first; drive uses that shell to materialize mentioned targets.
assert DIFY_CONFIG_LAYER_ID in layer_names
# shell enters first; config uses that shell to materialize mentioned targets.
assert layer_names.index(DIFY_SHELL_LAYER_ID) == layer_names.index("execution_context") + 1
assert layer_names.index("drive") == layer_names.index(DIFY_SHELL_LAYER_ID) + 1
drive = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "drive")
assert drive["type"] == "dify.drive"
assert drive["deps"] == {"shell": DIFY_SHELL_LAYER_ID}
assert drive["config"]["drive_ref"] == "agent-agent-1"
assert drive["config"]["skills"] == [
{
"path": "tender-analyzer",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
}
]
assert drive["config"]["mentioned_skill_keys"] == ["tender-analyzer/SKILL.md"]
assert drive["config"]["mentioned_file_keys"] == ["files/sample.pdf"]
assert layer_names.index(DIFY_CONFIG_LAYER_ID) == layer_names.index(DIFY_SHELL_LAYER_ID) + 1
config = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == DIFY_CONFIG_LAYER_ID)
assert config["type"] == "dify.config"
assert config["deps"] == {"shell": DIFY_SHELL_LAYER_ID}
assert config["config"] == {
"agent_id": "agent-1",
"config_version": {"id": "snapshot-1", "kind": "snapshot", "writable": False},
"skills": [
{
"name": "tender-analyzer",
"description": "Parses RFPs.",
"size": None,
"mime_type": "application/zip",
}
],
"files": [{"name": "sample.pdf", "size": None, "mime_type": None}],
"env_keys": [],
"note": "Read the proposal first.",
"mentioned_skill_names": ["tender-analyzer"],
"mentioned_file_names": ["sample.pdf"],
}
warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"]
assert warnings == []
# the drive layer is non-sensitive and must survive into persistable specs
# the config layer is non-sensitive and must survive into persistable specs
from dify_agent.protocol import extract_runtime_layer_specs
specs = extract_runtime_layer_specs(result.request.composition)
assert any(spec.name == "drive" and spec.type == "dify.drive" for spec in specs)
assert any(spec.name == DIFY_CONFIG_LAYER_ID and spec.type == "dify.config" for spec in specs)
def test_workflow_runtime_expands_drive_mentions_in_agent_soul_prompt(monkeypatch: pytest.MonkeyPatch):
def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
_mock_drive_catalog(monkeypatch)
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()
context.snapshot.config_snapshot = _soul_with_config_assets()
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert soul_prompt.config.prefix == "You are careful. Use Tender Analyzer and sample.pdf."
assert soul_prompt.config.prefix == "You are careful. Use tender-analyzer and sample.pdf."
assert "[§" not in soul_prompt.config.prefix
def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded_key(monkeypatch: pytest.MonkeyPatch):
def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
prompt={
"system_prompt": (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
"and [§file:files%2Fno-label.txt§]."
"Use [§skill:ghost-skill:Ghost Skill§], [§file:ghost.txt:Ghost File§], and [§file:no-label.txt§]."
)
},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
@@ -1321,34 +1403,36 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and files/no-label.txt."
assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and no-label.txt."
assert "[§" not in soul_prompt.config.prefix
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled(monkeypatch: pytest.MonkeyPatch):
def test_workflow_run_request_has_no_config_layer_when_flag_disabled(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()
context.snapshot.config_snapshot = _soul_with_config_assets()
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
dumped = result.request.model_dump(mode="json")
assert all(layer["name"] != "drive" for layer in dumped["composition"]["layers"])
assert all(layer["name"] != DIFY_CONFIG_LAYER_ID for layer in dumped["composition"]["layers"])
assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == []
def test_build_drive_layer_config_missing_mentions_warn_but_keep_skill_catalog(monkeypatch: pytest.MonkeyPatch):
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
def test_build_config_layer_config_missing_mentions_warn_without_catalog():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
_mock_drive_catalog(monkeypatch)
soul = AgentSoulConfig(
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
prompt={"system_prompt": "Use [§skill:ghost%2FSKILL.md:Ghost§]"},
config_skills=[{"name": "tender-analyzer", "description": "Parses RFPs.", "file_id": "tool-file-1"}],
prompt={"system_prompt": "Use [§skill:ghost-skill:Ghost§]"},
)
config, warnings = build_drive_layer_config(soul, tenant_id="tenant-1", agent_id="agent-1")
config, warnings = build_config_layer_config(soul)
assert config is not None
assert config.mentioned_skill_names == []
assert config.mentioned_file_names == []
assert [w["code"] for w in warnings] == ["mention_target_missing"]
@@ -236,6 +236,62 @@ def test_load_workflow_composer_uses_inline_preview_snapshot(monkeypatch: pytest
assert result == {"agent": "inline-agent-1", "version": "inline-preview-version"}
def test_workflow_inline_debug_conversation_seed(monkeypatch: pytest.MonkeyPatch):
captured: dict[str, object] = {}
class FakeRosterService:
def __init__(self, session):
captured["session"] = session
def get_or_create_agent_app_debug_conversation_id(self, **kwargs):
captured.update(kwargs)
return "debug-conversation-1"
monkeypatch.setattr(roster_service, "AgentRosterService", FakeRosterService)
binding = SimpleNamespace(binding_type=WorkflowAgentBindingType.INLINE_AGENT)
agent = SimpleNamespace(id="inline-agent-1", scope=AgentScope.WORKFLOW_ONLY)
debug_conversation_id = AgentComposerService._workflow_inline_debug_conversation_id(
tenant_id="tenant-1",
binding=binding,
agent=agent,
account_id="account-1",
)
assert debug_conversation_id == "debug-conversation-1"
assert captured["tenant_id"] == "tenant-1"
assert captured["agent_id"] == "inline-agent-1"
assert captured["account_id"] == "account-1"
def test_workflow_inline_debug_conversation_seed_skips_non_inline(monkeypatch: pytest.MonkeyPatch):
class UnexpectedRosterService:
def __init__(self, session):
raise AssertionError("roster service should not be used")
monkeypatch.setattr(roster_service, "AgentRosterService", UnexpectedRosterService)
assert (
AgentComposerService._workflow_inline_debug_conversation_id(
tenant_id="tenant-1",
binding=SimpleNamespace(binding_type=WorkflowAgentBindingType.ROSTER_AGENT),
agent=SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER),
account_id="account-1",
)
is None
)
assert (
AgentComposerService._workflow_inline_debug_conversation_id(
tenant_id="tenant-1",
binding=SimpleNamespace(binding_type=WorkflowAgentBindingType.INLINE_AGENT),
agent=SimpleNamespace(id="inline-agent-1", scope=AgentScope.WORKFLOW_ONLY),
account_id=None,
)
is None
)
def test_load_workflow_composer_rejects_preview_without_binding(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None)
@@ -267,6 +323,7 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy,
current_snapshot_id="version-1",
)
calls = []
serialize_calls = []
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
@@ -282,7 +339,12 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy,
"_get_version_if_present",
lambda **kwargs: SimpleNamespace(id="version-1"),
)
monkeypatch.setattr(AgentComposerService, "_serialize_workflow_state", lambda **kwargs: {"state": "ok"})
def serialize_workflow_state(**kwargs):
serialize_calls.append(kwargs)
return {"state": "ok"}
monkeypatch.setattr(AgentComposerService, "_serialize_workflow_state", serialize_workflow_state)
def save_helper(**kwargs):
calls.append(kwargs)
@@ -304,6 +366,7 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy,
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"state": "ok"}
assert calls
assert serialize_calls[0]["account_id"] == "account-1"
assert fake_session.commits == 1
@@ -437,10 +500,12 @@ def test_save_agent_app_composer_rejects_version_save_strategy():
def test_save_agent_app_composer_updates_normal_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 = {}
@@ -451,6 +516,7 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
"_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(
{
@@ -473,6 +539,43 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
assert fake_session.commits == 1
def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snapshot(monkeypatch: pytest.MonkeyPatch):
agent_soul = _agent_soul_with_model()
agent = SimpleNamespace(
id="agent-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="version-1",
active_config_is_published=False,
updated_by=None,
)
active_version = SimpleNamespace(config_snapshot_dict=agent_soul.model_dump(mode="json"))
fake_session = FakeSession(scalar=[agent], scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]])
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
monkeypatch.setattr(
AgentComposerService,
"_save_agent_draft",
lambda **_kwargs: 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": ComposerVariant.AGENT_APP.value,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
"agent_soul": agent_soul.model_dump(mode="json"),
}
)
AgentComposerService.save_agent_app_composer(
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
)
assert agent.active_config_is_published is True
assert fake_session.commits == 1
def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch):
agent = Agent(
id="agent-1",
@@ -565,7 +668,11 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict
assert fake_session.commits == 1
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft])
active_version = SimpleNamespace(config_snapshot_dict=build_draft.config_snapshot_dict)
fake_session = FakeSession(
scalar=[agent, build_draft, normal_draft, active_version],
scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]],
)
monkeypatch.setattr(composer_service.db, "session", fake_session)
applied = AgentComposerService.apply_agent_app_build_draft(
@@ -576,6 +683,62 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
assert applied["result"] == "success"
assert applied["draft"]["id"] == normal_draft.id
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
assert agent.active_config_is_published is True
assert fake_session.deleted == [build_draft]
assert fake_session.commits == 1
def test_agent_app_build_draft_apply_marks_unpublished_when_build_draft_differs(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=True,
)
active_agent_soul = _agent_soul_with_model()
build_agent_soul = AgentSoulConfig.model_validate(
{
**active_agent_soul.model_dump(mode="json"),
"prompt": {
"system_prompt": "Build draft prompt",
},
}
)
build_draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-1",
draft_owner_key="account-1",
base_snapshot_id="version-1",
config_snapshot=build_agent_soul,
)
normal_draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft_owner_key="",
base_snapshot_id="version-1",
config_snapshot=active_agent_soul,
)
active_version = SimpleNamespace(config_snapshot_dict=active_agent_soul.model_dump(mode="json"))
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft, active_version])
monkeypatch.setattr(composer_service.db, "session", fake_session)
AgentComposerService.apply_agent_app_build_draft(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="account-1",
)
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
assert agent.active_config_is_published is False
assert fake_session.deleted == [build_draft]
@@ -690,6 +853,53 @@ def test_serialize_workflow_state_passes_user_declared_outputs_through_effective
assert effective[0]["required"] is True
def test_serialize_workflow_state_includes_inline_debug_conversation_message_state(
monkeypatch: pytest.MonkeyPatch,
):
binding = WorkflowAgentNodeBinding(
id="binding-1",
tenant_id="tenant-1",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id="agent-1",
current_snapshot_id="version-1",
workflow_id="workflow-1",
node_id="node-1",
node_job_config='{"workflow_prompt":"work"}',
)
agent = Agent(
id="agent-1",
name="Inline Agent",
description="",
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
status=AgentStatus.ACTIVE,
backing_app_id="backing-app-1",
)
version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}')
monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1})
monkeypatch.setattr(
AgentComposerService,
"_workflow_inline_debug_conversation_id",
lambda **kwargs: "debug-conversation-1",
)
monkeypatch.setattr(
roster_service.AgentRosterService,
"count_agent_app_debug_conversation_messages",
lambda self, *, conversation_id: 2,
)
state = AgentComposerService._serialize_workflow_state(
binding=binding,
agent=agent,
version=version,
account_id="account-1",
)
assert state["debug_conversation_id"] == "debug-conversation-1"
assert state["debug_conversation_has_messages"] is True
assert state["debug_conversation_message_count"] == 2
def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.MonkeyPatch):
fake_session = FakeSession()
monkeypatch.setattr(composer_service.db, "session", fake_session)
@@ -1215,16 +1425,18 @@ def test_copy_workflow_composer_from_roster_is_idempotent_when_already_inline(mo
version=1,
config_snapshot='{"prompt":{"system_prompt":"inline"}}',
)
serialize_calls = []
monkeypatch.setattr(composer_service.db, "session", FakeSession())
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: inline_binding)
monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: inline_agent)
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: inline_version)
monkeypatch.setattr(
AgentComposerService,
"_serialize_workflow_state",
lambda **kwargs: {"binding_type": kwargs["binding"].binding_type.value},
)
def serialize_workflow_state(**kwargs):
serialize_calls.append(kwargs)
return {"binding_type": kwargs["binding"].binding_type.value}
monkeypatch.setattr(AgentComposerService, "_serialize_workflow_state", serialize_workflow_state)
state = AgentComposerService.copy_workflow_composer_from_roster(
tenant_id="tenant-1",
@@ -1236,6 +1448,7 @@ def test_copy_workflow_composer_from_roster_is_idempotent_when_already_inline(mo
)
assert state == {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value}
assert serialize_calls[0]["account_id"] == "account-1"
@pytest.mark.parametrize(
@@ -1611,6 +1824,52 @@ def test_composer_create_roster_agent_raises_when_backing_agent_missing(monkeypa
)
def test_agent_app_draft_match_does_not_mark_create_version_as_published(monkeypatch: pytest.MonkeyPatch):
agent_soul = AgentSoulConfig()
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="snapshot-1",
)
snapshot = SimpleNamespace(config_snapshot_dict=agent_soul)
fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.CREATE_VERSION]])
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot)
assert (
AgentComposerService._agent_soul_matches_active_config(
tenant_id="tenant-1",
agent=agent,
agent_soul=agent_soul,
)
is False
)
def test_agent_app_draft_match_marks_publish_visible_revision_as_published(monkeypatch: pytest.MonkeyPatch):
agent_soul = AgentSoulConfig()
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="snapshot-1",
)
snapshot = SimpleNamespace(config_snapshot_dict=agent_soul)
fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]])
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot)
assert (
AgentComposerService._agent_soul_matches_active_config(
tenant_id="tenant-1",
agent=agent,
agent_soul=agent_soul,
)
is True
)
def test_composer_version_helpers_and_lookup_errors(monkeypatch: pytest.MonkeyPatch):
fake_session = FakeSession(
scalar=[
@@ -2220,6 +2479,16 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate():
assert recreate_session.commits == 1
def test_agent_app_debug_conversation_message_count():
session = FakeSession(scalar=[3])
count = AgentRosterService(session).count_agent_app_debug_conversation_messages(
conversation_id="debug-conversation-1",
)
assert count == 3
def test_agent_app_debug_conversation_requires_app_binding():
agent = Agent(
id="agent-1",
@@ -241,7 +241,21 @@ def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig):
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == ("Read START/tenders and produce qna_report (file); if unsure contact EMAIL · David Hayes.")
assert expanded == (
"Read START/tenders and produce qna_report (file output; create the file locally, run "
"`dify-agent file upload <path>`, then copy the returned AgentStubFileMapping JSON "
"as final_output.qna_report; do not call final_output before upload succeeds, and do not use "
"the local path, filename, URL, or a synthesized dify-file-ref as the reference); "
"if unsure contact EMAIL · David Hayes."
)
def test_node_job_resolver_accepts_legacy_reversed_output_token(node_job: WorkflowNodeJobConfig):
resolver = build_node_job_mention_resolver(node_job)
expanded = expand_prompt_mentions("[§qna_report:qna_report:output§]", resolver)
assert "final_output.qna_report" in expanded
def test_node_job_resolver_matches_ref_by_node_id_and_output_fields():
@@ -0,0 +1,661 @@
"""Focused tests for the Agent Soul-backed config service."""
from __future__ import annotations
import io
import zipfile
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from models.agent_config_entities import (
AgentConfigFileRefConfig,
AgentConfigSkillRefConfig,
AgentEnvVariableConfig,
AgentSoulConfig,
)
from services.agent.skill_package_service import SkillPackageError
from services.agent_config_service import (
AgentConfigService,
AgentConfigServiceError,
AgentConfigTarget,
AgentConfigVersionKind,
ConfigPushPayload,
ConfigPushSkillItem,
)
MODULE = "services.agent_config_service"
TENANT = "tenant-1"
AGENT = "agent-1"
USER = "user-1"
def _session_cm(session: MagicMock) -> MagicMock:
context_manager = MagicMock()
context_manager.__enter__.return_value = session
context_manager.__exit__.return_value = None
return context_manager
def _soul(**updates) -> AgentSoulConfig:
payload = AgentSoulConfig().model_dump(mode="json")
payload.update(updates)
return AgentSoulConfig.model_validate(payload)
def _version(*, version_id: str = "version-1", snapshot: AgentSoulConfig | None = None) -> SimpleNamespace:
agent_soul = snapshot or _soul()
return SimpleNamespace(
id=version_id,
config_snapshot_dict=agent_soul.model_dump(mode="json"),
config_snapshot=agent_soul,
)
def _target(
*,
kind: AgentConfigVersionKind,
writable: bool,
version_id: str = "version-1",
soul: AgentSoulConfig | None = None,
) -> AgentConfigTarget:
agent_soul = soul or _soul()
return AgentConfigTarget(
agent_id=AGENT,
version_id=version_id,
kind=kind,
writable=writable,
version=_version(version_id=version_id, snapshot=agent_soul),
agent_soul=agent_soul,
)
def _zip_bytes(members: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for name, payload in members.items():
archive.writestr(name, payload)
return buffer.getvalue()
@pytest.mark.parametrize(
("kind", "user_id", "version_row", "expected_writable"),
[
(AgentConfigVersionKind.SNAPSHOT, None, _version(version_id="snapshot-1"), False),
(AgentConfigVersionKind.DRAFT, USER, _version(version_id="draft-1"), False),
(AgentConfigVersionKind.BUILD_DRAFT, USER, _version(version_id="build-draft-1"), True),
],
)
def test_resolve_target_supports_snapshot_draft_and_build_draft(
kind: AgentConfigVersionKind,
user_id: str | None,
version_row: SimpleNamespace,
expected_writable: bool,
) -> None:
session = MagicMock()
session.scalar.side_effect = [AGENT, version_row]
service = AgentConfigService()
with patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)):
target = service.resolve_target(
tenant_id=TENANT,
agent_id=AGENT,
config_version_id=version_row.id,
config_version_kind=kind,
user_id=user_id,
)
assert target.agent_id == AGENT
assert target.version_id == version_row.id
assert target.kind == kind
assert target.writable is expected_writable
def test_resolve_target_requires_user_for_build_draft() -> None:
session = MagicMock()
session.scalar.side_effect = [AGENT]
service = AgentConfigService()
with patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)):
with pytest.raises(AgentConfigServiceError, match="user_id is required") as exc_info:
service.resolve_target(
tenant_id=TENANT,
agent_id=AGENT,
config_version_id="build-draft-1",
config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
)
assert exc_info.value.code == "missing_user_id"
@pytest.mark.parametrize(
("first_scalar", "expected_code"),
[
(None, "agent_not_found"),
(AGENT, "config_version_not_found"),
],
)
def test_resolve_target_maps_missing_agent_and_version(first_scalar: str | None, expected_code: str) -> None:
session = MagicMock()
if first_scalar is None:
session.scalar.return_value = None
else:
session.scalar.side_effect = [first_scalar, None]
service = AgentConfigService()
with patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)):
with pytest.raises(AgentConfigServiceError) as exc_info:
service.resolve_target(
tenant_id=TENANT,
agent_id=AGENT,
config_version_id="missing",
config_version_kind=AgentConfigVersionKind.SNAPSHOT,
user_id=USER,
)
assert exc_info.value.code == expected_code
def test_push_rejects_non_build_draft_writes() -> None:
session = MagicMock()
service = AgentConfigService()
with (
patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)),
patch.object(
service,
"_resolve_target_in_session",
return_value=_target(kind=AgentConfigVersionKind.DRAFT, writable=False),
),
):
with pytest.raises(AgentConfigServiceError, match="build drafts") as exc_info:
service.push(
tenant_id=TENANT,
agent_id=AGENT,
user_id=USER,
config_version_id="draft-1",
config_version_kind=AgentConfigVersionKind.DRAFT,
payload=ConfigPushPayload(note="ignored"),
)
assert exc_info.value.code == "config_not_writable"
session.commit.assert_not_called()
def test_push_for_console_allows_shared_draft_mutations() -> None:
session = MagicMock()
service = AgentConfigService()
target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False, soul=_soul(config_note="before"))
with (
patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)),
patch.object(service, "_resolve_target_in_session", return_value=target),
):
manifest = service.push_for_console(
tenant_id=TENANT,
agent_id=AGENT,
user_id=USER,
config_version_id="draft-1",
config_version_kind=AgentConfigVersionKind.DRAFT,
payload=ConfigPushPayload(note="after"),
)
assert manifest["note"] == "after"
assert target.version.config_snapshot.config_note == "after"
session.commit.assert_called_once()
def test_push_accepts_tenant_scoped_tool_file_sources_from_different_upload_owner() -> None:
session = MagicMock()
service = AgentConfigService()
target = _target(kind=AgentConfigVersionKind.BUILD_DRAFT, writable=True)
file_source = SimpleNamespace(
id="tool-file-file",
tenant_id=TENANT,
user_id="end-user-1",
size=7,
mimetype="text/plain",
file_key="file-key",
name="guide.txt",
)
skill_source = SimpleNamespace(
id="tool-file-skill",
tenant_id=TENANT,
user_id="end-user-1",
size=123,
mimetype="application/zip",
file_key="skill-key",
name="alpha.zip",
)
skill_ref = AgentConfigSkillRefConfig(
name="alpha",
description="Alpha skill",
file_id="normalized-skill-file",
size=321,
mime_type="application/zip",
)
with (
patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)),
patch.object(service, "_resolve_target_in_session", return_value=target),
patch.object(service, "_require_tool_file_source", side_effect=[file_source, skill_source]) as require_source,
patch(f"{MODULE}.storage.load_once", return_value=b"skill-archive"),
patch.object(service._skill_normalizer, "normalize", return_value=(skill_ref, object())),
):
manifest = service.push(
tenant_id=TENANT,
agent_id=AGENT,
user_id=USER,
config_version_id="build-draft-1",
config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
payload=ConfigPushPayload.model_validate(
{
"files": [{"name": "guide.txt", "file_ref": {"kind": "tool_file", "id": "tool-file-file"}}],
"skills": [{"name": "alpha", "file_ref": {"kind": "tool_file", "id": "tool-file-skill"}}],
}
),
)
assert [call.args for call in require_source.call_args_list] == [(session,), (session,)]
assert [call.kwargs for call in require_source.call_args_list] == [
{"tenant_id": TENANT, "file_id": "tool-file-file"},
{"tenant_id": TENANT, "file_id": "tool-file-skill"},
]
files = manifest["files"]
skills = manifest["skills"]
assert isinstance(files, dict)
assert isinstance(skills, dict)
assert files["items"][0]["file_id"] == "tool-file-file"
assert skills["items"][0]["file_id"] == "normalized-skill-file"
session.commit.assert_called_once()
def test_push_file_for_console_rejects_snapshot_writes() -> None:
session = MagicMock()
service = AgentConfigService()
with (
patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)),
patch.object(
service,
"_resolve_target_in_session",
return_value=_target(kind=AgentConfigVersionKind.SNAPSHOT, writable=False),
),
):
with pytest.raises(AgentConfigServiceError, match="editable drafts") as exc_info:
service.push_file_for_console(
tenant_id=TENANT,
agent_id=AGENT,
user_id=USER,
config_version_id="snapshot-1",
config_version_kind=AgentConfigVersionKind.SNAPSHOT,
upload_file_id="upload-1",
)
assert exc_info.value.code == "config_not_writable"
def test_push_file_for_console_uses_service_owned_upload_lookup_and_naming() -> None:
session = MagicMock()
service = AgentConfigService()
target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False)
upload_file = SimpleNamespace(
id="upload-1",
name="guide.txt",
size=7,
hash="sha256:abc",
mime_type="text/plain",
)
with (
patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)),
patch.object(service, "_resolve_target_in_session", return_value=target),
patch.object(service, "_require_console_upload_file_source", return_value=upload_file),
):
response = service.push_file_for_console(
tenant_id=TENANT,
agent_id=AGENT,
user_id=USER,
config_version_id="draft-1",
config_version_kind=AgentConfigVersionKind.DRAFT,
upload_file_id="upload-1",
)
assert response == {
"file": {
"id": "guide.txt",
"name": "guide.txt",
"file_id": "upload-1",
"size": 7,
"hash": "sha256:abc",
"mime_type": "text/plain",
},
"config_version": {
"id": "version-1",
"kind": "draft",
"writable": True,
},
}
session.commit.assert_called_once()
def test_apply_skill_updates_rejects_non_tool_file_refs() -> None:
service = AgentConfigService()
with pytest.raises(AgentConfigServiceError, match="tool files") as exc_info:
service._apply_skill_updates(
MagicMock(),
tenant_id=TENANT,
user_id=USER,
current=[],
updates=[
ConfigPushSkillItem.model_validate(
{"name": "alpha", "file_ref": {"kind": "upload_file", "id": "upload-1"}}
)
],
)
assert exc_info.value.code == "invalid_skill_file_ref"
@pytest.mark.parametrize(
("error_code", "message"),
[
("skill_name_mismatch", "skill name does not match requested config key"),
("invalid_archive", "stored tool file is not a valid skill archive"),
],
)
def test_apply_skill_updates_maps_normalizer_failures(error_code: str, message: str) -> None:
service = AgentConfigService()
tool_file = SimpleNamespace(name="alpha.zip", file_key="tool-files/alpha.zip")
with (
patch.object(service, "_require_tool_file_source", return_value=tool_file),
patch(f"{MODULE}.storage.load_once", return_value=b"bad-archive"),
patch.object(
service._skill_normalizer,
"normalize",
side_effect=SkillPackageError(error_code, message, status_code=400),
),
):
with pytest.raises(AgentConfigServiceError, match=message) as exc_info:
service._apply_skill_updates(
MagicMock(),
tenant_id=TENANT,
user_id=USER,
current=[],
updates=[
ConfigPushSkillItem.model_validate(
{"name": "alpha", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}
)
],
)
assert exc_info.value.code == error_code
def test_apply_env_text_supports_delete_comments_export_and_keeps_unmentioned_values() -> None:
current = [
AgentEnvVariableConfig(key="KEEP", name="KEEP", value="old"),
AgentEnvVariableConfig(key="REMOVE", name="REMOVE", value="gone"),
AgentEnvVariableConfig(key="UNTOUCHED", name="UNTOUCHED", value="still-here"),
]
updated = AgentConfigService._apply_env_text(
current,
"# comment\nexport KEEP=new-value\nREMOVE=\nNEW='two words'\n",
)
values = {item.key: item.value for item in updated}
assert values == {
"KEEP": "new-value",
"UNTOUCHED": "still-here",
"NEW": "two words",
}
@pytest.mark.parametrize(
"archive_bytes",
[
b"not-a-zip-archive",
_zip_bytes({"README.md": b"missing skill md"}),
],
)
def test_inspect_skill_maps_invalid_archives_to_service_errors(archive_bytes: bytes) -> None:
service = AgentConfigService()
target = _target(
kind=AgentConfigVersionKind.BUILD_DRAFT,
writable=True,
soul=_soul(config_skills=[AgentConfigSkillRefConfig(name="alpha", file_id="tool-file-1")]),
)
with (
patch.object(service, "resolve_target", return_value=target),
patch.object(service, "_load_tool_file_bytes", return_value=(archive_bytes, "application/zip")),
):
with pytest.raises(AgentConfigServiceError, match="stored config skill archive is invalid") as exc_info:
service.inspect_skill(
tenant_id=TENANT,
agent_id=AGENT,
config_version_id="build-draft-1",
config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
name="alpha",
user_id=USER,
)
assert exc_info.value.code == "skill_archive_invalid"
assert exc_info.value.status_code == 500
def test_manifest_uses_items_shape_without_download_urls() -> None:
target = _target(
kind=AgentConfigVersionKind.DRAFT,
writable=False,
soul=_soul(
config_skills=[AgentConfigSkillRefConfig(name="alpha", description="Alpha skill", file_id="tool-file-1")],
config_files=[AgentConfigFileRefConfig(name="guide.txt", file_kind="upload_file", file_id="upload-file-1")],
config_note="Use the guide.",
),
)
manifest = AgentConfigService._manifest_for_target(target)
assert manifest == {
"agent_id": AGENT,
"config_version": {
"id": "version-1",
"kind": "draft",
"writable": True,
},
"skills": {
"items": [
{
"id": "alpha",
"name": "alpha",
"file_id": "tool-file-1",
"description": "Alpha skill",
"size": None,
"hash": None,
"mime_type": "application/zip",
}
]
},
"files": {
"items": [
{
"id": "guide.txt",
"name": "guide.txt",
"file_id": "upload-file-1",
"size": None,
"hash": None,
"mime_type": None,
}
]
},
"env_keys": [],
"note": "Use the guide.",
}
def test_preview_skill_file_returns_text_preview() -> None:
service = AgentConfigService()
target = _target(
kind=AgentConfigVersionKind.BUILD_DRAFT,
writable=True,
soul=_soul(config_skills=[AgentConfigSkillRefConfig(name="alpha", file_id="tool-file-1")]),
)
archive_bytes = _zip_bytes(
{
"SKILL.md": b"# Alpha\n",
"references/guide.md": b"hello world",
}
)
with (
patch.object(service, "resolve_target", return_value=target),
patch.object(service, "_load_tool_file_bytes", return_value=(archive_bytes, "application/zip")),
):
preview = service.preview_skill_file(
tenant_id=TENANT,
agent_id=AGENT,
config_version_id="build-draft-1",
config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
name="alpha",
path="references/guide.md",
user_id=USER,
)
assert preview == {
"path": "references/guide.md",
"size": 11,
"truncated": False,
"binary": False,
"text": "hello world",
}
def test_preview_skill_file_marks_binary_and_truncated_payloads() -> None:
service = AgentConfigService()
target = _target(
kind=AgentConfigVersionKind.BUILD_DRAFT,
writable=True,
soul=_soul(config_skills=[AgentConfigSkillRefConfig(name="alpha", file_id="tool-file-1")]),
)
archive_bytes = _zip_bytes(
{
"SKILL.md": b"# Alpha\n",
"bin/data.bin": b"\x00" + (b"x" * (AgentConfigService.PREVIEW_MAX_BYTES + 10)),
}
)
with (
patch.object(service, "resolve_target", return_value=target),
patch.object(service, "_load_tool_file_bytes", return_value=(archive_bytes, "application/zip")),
):
preview = service.preview_skill_file(
tenant_id=TENANT,
agent_id=AGENT,
config_version_id="build-draft-1",
config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
name="alpha",
path="bin/data.bin",
user_id=USER,
)
assert preview == {
"path": "bin/data.bin",
"size": AgentConfigService.PREVIEW_MAX_BYTES + 11,
"truncated": True,
"binary": True,
"text": None,
}
def test_resolve_skill_file_member_path_requires_existing_member() -> None:
service = AgentConfigService()
target = _target(
kind=AgentConfigVersionKind.BUILD_DRAFT,
writable=True,
soul=_soul(config_skills=[AgentConfigSkillRefConfig(name="alpha", file_id="tool-file-1")]),
)
archive_bytes = _zip_bytes(
{
"SKILL.md": b"# Alpha\n",
"references/guide.md": b"hello world",
}
)
with (
patch.object(service, "resolve_target", return_value=target),
patch.object(service, "_load_tool_file_bytes", return_value=(archive_bytes, "application/zip")),
):
assert (
service.resolve_skill_file_member_path(
tenant_id=TENANT,
agent_id=AGENT,
config_version_id="build-draft-1",
config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
name="alpha",
path="references/guide.md",
user_id=USER,
)
== "references/guide.md"
)
with pytest.raises(AgentConfigServiceError, match="config skill file not found") as exc_info:
service.resolve_skill_file_member_path(
tenant_id=TENANT,
agent_id=AGENT,
config_version_id="build-draft-1",
config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
name="alpha",
path="references/missing.md",
user_id=USER,
)
assert exc_info.value.code == "config_skill_file_not_found"
assert exc_info.value.status_code == 404
def test_download_url_helpers_use_shared_url_resolution() -> None:
service = AgentConfigService()
target = _target(
kind=AgentConfigVersionKind.BUILD_DRAFT,
writable=True,
soul=_soul(
config_skills=[AgentConfigSkillRefConfig(name="alpha", file_id="tool-file-1")],
config_files=[AgentConfigFileRefConfig(name="guide.txt", file_kind="upload_file", file_id="upload-file-1")],
),
)
with (
patch.object(service, "resolve_target", return_value=target),
patch.object(
service,
"_resolve_download_url",
side_effect=["https://example.com/alpha.zip", "https://example.com/guide.txt"],
),
):
assert (
service.download_skill_url(
tenant_id=TENANT,
agent_id=AGENT,
config_version_id="build-draft-1",
config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
name="alpha",
user_id=USER,
)
== "https://example.com/alpha.zip"
)
assert (
service.download_file_url(
tenant_id=TENANT,
agent_id=AGENT,
config_version_id="build-draft-1",
config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
name="guide.txt",
user_id=USER,
)
== "https://example.com/guide.txt"
)
@@ -0,0 +1,166 @@
"""Unit tests for the Agent tool inner invoke service."""
from collections.abc import Generator
from unittest.mock import MagicMock, patch
import pytest
from core.tools.entities.tool_entities import ToolInvokeMessage, ToolProviderType
from core.tools.errors import (
ToolInvokeError,
ToolParameterValidationError,
ToolProviderCredentialValidationError,
ToolProviderNotFoundError,
)
from services.agent_tool_inner_service import AgentToolInnerService
from services.entities.agent_tool_inner import AgentToolInvokeRequest
from services.errors.agent_tool_inner import AgentToolInnerServiceError
def _request() -> AgentToolInvokeRequest:
return AgentToolInvokeRequest.model_validate(
{
"caller": {
"tenant_id": "tenant-1",
"user_id": "user-1",
"user_from": "account",
"app_id": "app-1",
"invoke_from": "service-api",
"conversation_id": "conversation-1",
"workflow_id": "workflow-1",
"workflow_run_id": "workflow-run-1",
"node_id": "node-1",
"node_execution_id": "node-exec-1",
"agent_id": "agent-1",
"agent_config_version_id": "snapshot-1",
},
"tool": {
"provider_type": "plugin",
"provider_id": "langgenius/search/search",
"tool_name": "search",
"credential_id": "credential-1",
"tool_parameters": {"query": "dify"},
"runtime_parameters": {"region": "us"},
},
}
)
def _messages() -> Generator[ToolInvokeMessage, None, None]:
yield ToolInvokeMessage(
type=ToolInvokeMessage.MessageType.TEXT,
message=ToolInvokeMessage.TextMessage(text="ok"),
)
def test_invoke_uses_agent_tool_runtime_and_returns_observation() -> None:
fake_tool = MagicMock()
fake_app = MagicMock(id="app-1", tenant_id="tenant-1")
session = MagicMock()
session.get.return_value = fake_app
with (
patch(
"services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime",
return_value=fake_tool,
) as mock_get_runtime,
patch("services.agent_tool_inner_service.ToolEngine.generic_invoke", return_value=_messages()) as mock_invoke,
patch(
"services.agent_tool_inner_service.ToolFileMessageTransformer.transform_tool_invoke_messages",
side_effect=lambda messages, **_kwargs: messages,
),
):
response = AgentToolInnerService().invoke(_request(), session=session)
assert response.observation == "ok"
assert response.metadata == {
"provider_type": "plugin",
"provider_id": "langgenius/search/search",
"tool_name": "search",
}
agent_tool = mock_get_runtime.call_args.kwargs["agent_tool"]
assert agent_tool.provider_type is ToolProviderType.PLUGIN
assert agent_tool.tool_parameters == {"region": "us"}
mock_invoke.assert_called_once()
def test_invoke_raises_app_not_found_when_session_has_no_app() -> None:
session = MagicMock()
session.get.return_value = None
with pytest.raises(AgentToolInnerServiceError) as exc_info:
AgentToolInnerService().invoke(_request(), session=session)
assert exc_info.value.error_code == "app_not_found"
assert exc_info.value.status_code == 404
assert exc_info.value.description == "App not found."
def test_invoke_raises_app_tenant_mismatch_when_app_belongs_to_other_tenant() -> None:
fake_app = MagicMock(id="app-1", tenant_id="tenant-2")
session = MagicMock()
session.get.return_value = fake_app
with pytest.raises(AgentToolInnerServiceError) as exc_info:
AgentToolInnerService().invoke(_request(), session=session)
assert exc_info.value.error_code == "app_tenant_mismatch"
assert exc_info.value.status_code == 403
assert exc_info.value.description == "App does not belong to the caller tenant."
def test_invoke_maps_tool_runtime_app_not_found_value_error_to_specific_error_code() -> None:
fake_tool = MagicMock()
fake_app = MagicMock(id="app-1", tenant_id="tenant-1")
session = MagicMock()
session.get.return_value = fake_app
with (
patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", return_value=fake_tool),
patch("services.agent_tool_inner_service.ToolEngine.generic_invoke", side_effect=ValueError("app not found")),
):
with pytest.raises(AgentToolInnerServiceError) as exc_info:
AgentToolInnerService().invoke(_request(), session=session)
assert exc_info.value.error_code == "app_not_found"
assert exc_info.value.status_code == 404
assert exc_info.value.description == "App not found."
def test_invoke_maps_tool_invoke_error_without_private_tool_engine_helper() -> None:
fake_tool = MagicMock()
fake_app = MagicMock(id="app-1", tenant_id="tenant-1")
session = MagicMock()
session.get.return_value = fake_app
with (
patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", return_value=fake_tool),
patch(
"services.agent_tool_inner_service.ToolEngine.generic_invoke",
side_effect=ToolInvokeError("workflow crashed"),
),
):
with pytest.raises(AgentToolInnerServiceError) as exc_info:
AgentToolInnerService().invoke(_request(), session=session)
assert exc_info.value.error_code == "agent_tool_invoke_failed"
@pytest.mark.parametrize(
("error", "expected_code"),
[
(ToolProviderNotFoundError("provider gone"), "agent_tool_declaration_not_found"),
(ToolProviderCredentialValidationError("credential invalid"), "agent_tool_credential_invalid"),
(ToolParameterValidationError("query is required"), "tool_parameters_invalid"),
],
)
def test_invoke_maps_runtime_lookup_errors_to_service_error_codes(error: Exception, expected_code: str) -> None:
fake_app = MagicMock(id="app-1", tenant_id="tenant-1")
session = MagicMock()
session.get.return_value = fake_app
with patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", side_effect=error):
with pytest.raises(AgentToolInnerServiceError) as exc_info:
AgentToolInnerService().invoke(_request(), session=session)
assert exc_info.value.error_code == expected_code
@@ -306,6 +306,22 @@ class TestGenerate:
assert result == {"result": "chat"}
gen_spy.assert_called_once()
def test_stateless_agent_mode(self, mocker: MockerFixture):
gen_spy = mocker.patch(
"services.app_generate_service.AgentAppGenerator.generate_stateless",
return_value={"result": "stateless-agent"},
)
result = AppGenerateService.generate_stateless_agent_app(
app_model=_make_app(AppMode.AGENT),
user=_make_user(),
args={"inputs": {}},
invoke_from=InvokeFrom.SERVICE_API,
)
assert result == {"result": "stateless-agent"}
gen_spy.assert_called_once()
# -- ADVANCED_CHAT blocking ---------------------------------------------
def test_advanced_chat_blocking(self, mocker: MockerFixture):
workflow = _make_workflow()
@@ -550,7 +566,136 @@ class TestGenerateBilling:
streaming=False,
)
# exit is called in finally block for non-streaming
assert len(exit_calls) >= 1
assert exit_calls == ["dummy-request-id"]
def test_stateless_agent_app_uses_billing_and_rate_limit_guardrails(
self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
quota_charge = MagicMock()
reserve_mock = mocker.patch(
"services.app_generate_service.QuotaService.reserve",
return_value=quota_charge,
)
exit_calls: list[str] = []
class _TrackingRateLimit(_DummyRateLimit):
def exit(self, request_id: str) -> None:
exit_calls.append(request_id)
mocker.patch("services.app_generate_service.RateLimit", _TrackingRateLimit)
gen_spy = mocker.patch(
"services.app_generate_service.AgentAppGenerator.generate_stateless",
return_value={"ok": True},
)
result = AppGenerateService.generate_stateless_agent_app(
app_model=_make_app(AppMode.AGENT),
user=_make_user(),
args={"inputs": {}},
invoke_from=InvokeFrom.SERVICE_API,
)
assert result == {"ok": True}
reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id")
quota_charge.commit.assert_called_once()
assert exit_calls == ["dummy-request-id"]
gen_spy.assert_called_once()
def test_stateless_agent_app_failure_refunds_quota_and_exits_once(
self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
quota_charge = MagicMock()
mocker.patch(
"services.app_generate_service.QuotaService.reserve",
return_value=quota_charge,
)
exit_calls: list[str] = []
class _TrackingRateLimit(_DummyRateLimit):
def exit(self, request_id: str) -> None:
exit_calls.append(request_id)
mocker.patch("services.app_generate_service.RateLimit", _TrackingRateLimit)
mocker.patch(
"services.app_generate_service.AgentAppGenerator.generate_stateless",
side_effect=RuntimeError("boom"),
)
with pytest.raises(RuntimeError, match="boom"):
AppGenerateService.generate_stateless_agent_app(
app_model=_make_app(AppMode.AGENT),
user=_make_user(),
args={"inputs": {}},
invoke_from=InvokeFrom.SERVICE_API,
)
quota_charge.commit.assert_called_once()
quota_charge.refund.assert_called_once()
assert exit_calls == ["dummy-request-id"]
def test_blocking_failure_exits_rate_limit_once(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
quota_charge = MagicMock()
mocker.patch(
"services.app_generate_service.QuotaService.reserve",
return_value=quota_charge,
)
exit_calls: list[str] = []
class _TrackingRateLimit(_DummyRateLimit):
def exit(self, request_id: str) -> None:
exit_calls.append(request_id)
mocker.patch("services.app_generate_service.RateLimit", _TrackingRateLimit)
mocker.patch(
"services.app_generate_service.CompletionAppGenerator.generate",
side_effect=RuntimeError("boom"),
)
with pytest.raises(RuntimeError, match="boom"):
AppGenerateService.generate(
app_model=_make_app(AppMode.COMPLETION),
user=_make_user(),
args={"inputs": {}},
invoke_from=InvokeFrom.SERVICE_API,
streaming=False,
)
quota_charge.refund.assert_called_once()
assert exit_calls == ["dummy-request-id"]
def test_streaming_failure_exits_rate_limit_once(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
quota_charge = MagicMock()
mocker.patch(
"services.app_generate_service.QuotaService.reserve",
return_value=quota_charge,
)
exit_calls: list[str] = []
class _TrackingRateLimit(_DummyRateLimit):
def exit(self, request_id: str) -> None:
exit_calls.append(request_id)
mocker.patch("services.app_generate_service.RateLimit", _TrackingRateLimit)
mocker.patch(
"services.app_generate_service.CompletionAppGenerator.generate",
side_effect=RuntimeError("boom"),
)
with pytest.raises(RuntimeError, match="boom"):
AppGenerateService.generate(
app_model=_make_app(AppMode.COMPLETION),
user=_make_user(),
args={"inputs": {}},
invoke_from=InvokeFrom.SERVICE_API,
streaming=True,
)
quota_charge.refund.assert_called_once()
assert exit_calls == ["dummy-request-id"]
# ---------------------------------------------------------------------------
@@ -10,6 +10,7 @@ from unittest.mock import MagicMock
from pytest_mock import MockerFixture
from core.app.entities.app_invoke_entities import InvokeFrom
from models.account import Account
from models.human_input import HumanInputForm
from models.model import App, Conversation, EndUser
@@ -45,7 +46,7 @@ def _wire_db(
def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture):
conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None)
conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP)
account = MagicMock()
app = MagicMock(tenant_id="tenant-1")
_wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account)
@@ -59,10 +60,11 @@ def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixtu
assert kwargs["conversation_id"] == "conv-1"
assert kwargs["user"] is account
assert kwargs["app_model"] is app
assert kwargs["invoke_from"] == InvokeFrom.WEB_APP
def test_resume_end_user_path(mocker: MockerFixture):
conversation = MagicMock(from_account_id=None, from_end_user_id="eu-1")
conversation = MagicMock(from_account_id=None, from_end_user_id="eu-1", invoke_from=InvokeFrom.WEB_APP)
end_user = MagicMock()
_wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, end_user=end_user)
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
@@ -72,6 +74,18 @@ def test_resume_end_user_path(mocker: MockerFixture):
assert gen.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user
def test_resume_preserves_debugger_invoke_from(mocker: MockerFixture):
conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.DEBUGGER)
account = MagicMock()
app = MagicMock(tenant_id="tenant-1")
_wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account)
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
assert gen.return_value.resume_after_form_submission.call_args.kwargs["invoke_from"] == InvokeFrom.DEBUGGER
def test_resume_returns_when_form_missing(mocker: MockerFixture):
_wire_db(mocker, form=None)
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
@@ -109,7 +123,7 @@ def test_resume_returns_when_conversation_missing(mocker: MockerFixture):
def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture):
conversation = MagicMock(from_account_id=None, from_end_user_id=None)
conversation = MagicMock(from_account_id=None, from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP)
_wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation)
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
@@ -119,7 +133,7 @@ def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture):
def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture):
conversation = MagicMock(from_account_id="acct-x", from_end_user_id=None)
conversation = MagicMock(from_account_id="acct-x", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP)
_wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation, account=None)
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
@@ -129,7 +143,7 @@ def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixtu
def test_resume_swallows_generator_exception(mocker: MockerFixture):
conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None)
conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP)
_wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, account=MagicMock())
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
gen.return_value.resume_after_form_submission.side_effect = RuntimeError("boom")
Generated
+49 -12
View File
@@ -1302,11 +1302,11 @@ requires-dist = [
{ name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" },
{ name = "protobuf", marker = "extra == 'grpc'", specifier = ">=6.33.5,<7.0.0" },
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
{ name = "pydantic-ai-slim", specifier = ">=1.85.1,<2.0.0" },
{ name = "pydantic-ai-slim", specifier = ">=1.102.0,<2.0.0" },
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.2.1" },
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.3.0" },
{ name = "typer", specifier = ">=0.16.1,<0.17" },
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
@@ -2650,15 +2650,15 @@ wheels = [
[[package]]
name = "genai-prices"
version = "0.0.59"
version = "0.0.67"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "httpx2" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/c8/b61a028b8d8ee286ffab3f9b9f1c9229087184e7d543cea4e349e11375b0/genai_prices-0.0.59.tar.gz", hash = "sha256:3e1c7dcd9b38163589c8cf4a9bcfd286c52ea57a3becdc062a2cbaa8295b08c4", size = 67406, upload-time = "2026-05-07T12:08:40.475Z" }
sdist = { url = "https://files.pythonhosted.org/packages/17/9e/f96ad08d62f7bd33a5b24e65d4eb220569714b9a2a8813ada2e1fa47b4dd/genai_prices-0.0.67.tar.gz", hash = "sha256:54e07eb6541fda377187a471c5dba21a81b439c57f8dc44d89db3103c29ca343", size = 80015, upload-time = "2026-06-24T20:16:23.661Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/f9/4693c127f9fab0a8d39c47c198e378ecafcb043463e6dd73df205eacbc13/genai_prices-0.0.59-py3-none-any.whl", hash = "sha256:88fd8818e6807374e5a5c03f293b574ade5f18a3060622080cdd94a03cf43115", size = 70509, upload-time = "2026-05-07T12:08:39.075Z" },
{ url = "https://files.pythonhosted.org/packages/dc/05/d1ca6b960a3305f86d1c5f4274f2ddf8c94611ec7edc436a01cd38a01742/genai_prices-0.0.67-py3-none-any.whl", hash = "sha256:08977f1e83b4132abcfc60dabf21ff13c2d25958afb9199e59c4407bf5c9ed3f", size = 82495, upload-time = "2026-06-24T20:16:22.4Z" },
]
[[package]]
@@ -3278,6 +3278,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpcore2"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" },
]
[[package]]
name = "httplib2"
version = "0.31.0"
@@ -3337,6 +3350,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "httpx2"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpcore2" },
{ name = "idna" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" },
]
[[package]]
name = "huggingface-hub"
version = "1.6.0"
@@ -5153,7 +5181,7 @@ wheels = [
[[package]]
name = "pydantic-ai-slim"
version = "1.94.0"
version = "1.107.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "genai-prices" },
@@ -5164,9 +5192,9 @@ dependencies = [
{ name = "pydantic-graph" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/0b/ce4992e0e29ba81ba48d5bba955c53b72e2cda3636f9b6417386ae7e45f7/pydantic_ai_slim-1.94.0.tar.gz", hash = "sha256:7d7b1d6aec4d0fd31533a4ef5848863e8513ec75e82910296247a08b737aa828", size = 640338, upload-time = "2026-05-12T07:03:55.486Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/26/ced63dfaabbc77f3beb86d59689cdea748e7ccffb6b419dbaf4780f211e8/pydantic_ai_slim-1.107.0.tar.gz", hash = "sha256:4616f689a92fcfecfecf2a7af27aca22f139a873cf6d7a8929eaeee9c0eedbb4", size = 779902, upload-time = "2026-06-10T14:53:10.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/dd/b104641c2af7044a788b1071159679aa755e5c9281f110fdc54b4729117b/pydantic_ai_slim-1.94.0-py3-none-any.whl", hash = "sha256:f47cf89c61ef45a48dd575a8b32707edfec2b33ef7af80aa069bde1ce3fb6795", size = 805546, upload-time = "2026-05-12T07:03:46.535Z" },
{ url = "https://files.pythonhosted.org/packages/15/57/71044e17f931b08cc3930bc0fe5a1e1fd37fa474ae826be004729ef1cb4a/pydantic_ai_slim-1.107.0-py3-none-any.whl", hash = "sha256:1af49bbae06a6c598f72c54d4734ba377100cac493c9a05fa8e089bebeae0da6", size = 964046, upload-time = "2026-06-10T14:53:03.333Z" },
]
[[package]]
@@ -5213,7 +5241,7 @@ wheels = [
[[package]]
name = "pydantic-graph"
version = "1.94.0"
version = "1.107.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -5221,9 +5249,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/80/c41aa5ccf7104eba172ff45e617967b0075b3fa34ab76cd3795d7d62334a/pydantic_graph-1.94.0.tar.gz", hash = "sha256:8f991c05d412c9d12d6560c1e131de48bfde12ebd27a0b196440620210f2d52c", size = 59252, upload-time = "2026-05-12T07:03:58.26Z" }
sdist = { url = "https://files.pythonhosted.org/packages/dd/c3/6e8c2d13b8701041f1b3eac5deb41f25d4dbfa479a190d5c6becc23f2a49/pydantic_graph-1.107.0.tar.gz", hash = "sha256:278dd89b3e33f3a2963ac949f27a53aef705c5d883a8ce5d06d23e6e3cfbd972", size = 62564, upload-time = "2026-06-10T14:53:13.366Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/7e/edcc6177d89174024bca1fb85044bec4855b96f4b724a310638283dfc8c5/pydantic_graph-1.94.0-py3-none-any.whl", hash = "sha256:c6e285abbc8a55d1b65162c238006913edd1ef05e63a29401a580e51f798503e", size = 73063, upload-time = "2026-05-12T07:03:50.651Z" },
{ url = "https://files.pythonhosted.org/packages/fc/72/621556e3f5068400d43a0375d38e5963de30256eaa5a702aba12e82ed0ff/pydantic_graph-1.107.0-py3-none-any.whl", hash = "sha256:71add94fe7e14c703977a895117c475aae6c0b02a774a036c4d00d9a63c78b00", size = 80106, upload-time = "2026-06-10T14:53:06.543Z" },
]
[[package]]
@@ -6552,6 +6580,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" },
]
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "typer"
version = "0.16.1"