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 13:07:23 +08:00
committed by GitHub
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"
+47 -13
View File
@@ -9,45 +9,79 @@
FROM python:3.12-slim-bookworm AS base
ARG NODE_VERSION=22.22.1
ARG PNPM_VERSION=11.9.0
ARG UV_VERSION=0.8.9
ARG DIFY_AGENT_TOOL_SPEC=.[grpc]
ARG SHELL_SESSION_MANAGER_TOOL_SPEC=shell-session-manager
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
DIFY_AGENT_STUB_DRIVE_BASE=/mnt/drive
DIFY_AGENT_STUB_DRIVE_BASE=/mnt/drive \
UV_TOOL_DIR=/opt/dify-agent-tools/envs \
UV_TOOL_BIN_DIR=/opt/dify-agent-tools/bin
ENV PATH="${UV_TOOL_BIN_DIR}:${PATH}"
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
file \
git \
jq \
less \
openssh-client \
procps \
ripgrep \
tmux \
unzip \
xz-utils \
zip \
&& node_arch="$(dpkg --print-architecture)" \
&& case "${node_arch}" in \
amd64) node_arch="x64" ;; \
arm64) node_arch="arm64" ;; \
*) echo "Unsupported Node.js architecture: ${node_arch}" >&2; exit 1 ;; \
esac \
&& node_dist="node-v${NODE_VERSION}-linux-${node_arch}" \
&& curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" \
&& curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/${node_dist}.tar.xz" \
&& grep " ${node_dist}.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
&& tar -xJf "${node_dist}.tar.xz" -C /usr/local --strip-components=1 \
&& rm -f SHASUMS256.txt "${node_dist}.tar.xz" \
&& npm install --global "pnpm@${PNPM_VERSION}" \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/*
ENV UV_VERSION=0.8.9
RUN python -m pip install --no-cache-dir "uv==${UV_VERSION}"
WORKDIR /opt/dify-agent
FROM base AS packages
FROM base AS tools
ENV SHELL_SESSION_MANAGER_VERSION=2.2.1
ARG DIFY_AGENT_TOOL_SPEC
ARG SHELL_SESSION_MANAGER_TOOL_SPEC
COPY pyproject.toml uv.lock README.md ./
COPY src ./src
RUN uv sync --frozen --no-dev --no-editable --extra grpc \
&& uv pip install --python .venv/bin/python "shell-session-manager==${SHELL_SESSION_MANAGER_VERSION}"
RUN uv export --frozen --no-dev --all-extras --no-emit-project --no-hashes \
> /tmp/dify-agent-constraints.txt \
&& uv tool install --force --python /usr/local/bin/python --no-python-downloads \
--constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${DIFY_AGENT_TOOL_SPEC}" \
&& uv tool install --force --python /usr/local/bin/python --no-python-downloads \
--constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${SHELL_SESSION_MANAGER_TOOL_SPEC}" \
&& rm -f /tmp/dify-agent-constraints.txt
FROM base AS production
ENV VIRTUAL_ENV=/opt/dify-agent/.venv
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
COPY --from=tools ${UV_TOOL_DIR} ${UV_TOOL_DIR}
COPY --from=tools ${UV_TOOL_BIN_DIR} ${UV_TOOL_BIN_DIR}
COPY --from=packages ${VIRTUAL_ENV} ${VIRTUAL_ENV}
RUN ln -s ${VIRTUAL_ENV}/bin/dify-agent /usr/local/bin/dify-agent \
&& ln -s ${VIRTUAL_ENV}/bin/shellctl /usr/local/bin/shellctl \
&& useradd --create-home --shell /bin/sh dify \
RUN useradd --create-home --shell /bin/sh dify \
&& mkdir -p /mnt/drive \
&& chown -R dify:dify /home/dify /mnt/drive
@@ -228,8 +228,14 @@ and pre-creates that directory with write access for the same user.
The provided `docker/local-sandbox/Dockerfile` installs:
- `tmux`, required by `shellctl` to manage shell jobs;
- `shell-session-manager==2.2.1`, which provides the `shellctl` CLI/server;
- common shell workspace tools: `git`, `openssh-client`, `jq`, `ripgrep`,
`unzip`, `zip`, `file`, `procps`, and `less`;
- `shell-session-manager==2.3.0` as a standalone uv tool, which provides the
`shellctl` CLI/server;
- `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell
workspace;
- the `dify-agent` Agent Stub client CLI, including its gRPC transport extra;
workspace and Python CLI tools can be installed with isolated tool
environments;
- `node==22.22.1` and `pnpm==11.9.0`, so JavaScript and TypeScript tooling can
run inside the shell workspace without per-job installation;
- the `dify-agent[grpc]` Agent Stub client CLI as a standalone uv tool;
- a non-root default user named `dify`.
+1 -1
View File
@@ -27,7 +27,7 @@ server = [
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"redis>=7.4.0,<8.0.0",
"shell-session-manager==2.2.1",
"shell-session-manager==2.3.0",
"uvicorn[standard]==0.46.0",
]
@@ -1,41 +1,30 @@
"""Provider-agnostic shell provisioning/execution adapter for the Dify agent.
The boundary protocols live in ``protocols``; the default shellctl backend in
``shellctl``; and env-var-driven provider selection in ``config``/``factory``.
``create_shell_provisioner`` is the recommended entry point for callers.
"""
"""Provider-agnostic shell adapter exports for the Dify agent."""
from dify_agent.adapters.shell.config import DEFAULT_SHELL_PROVIDER, ShellAdapterSettings
from dify_agent.adapters.shell.factory import create_shell_provisioner
from dify_agent.adapters.shell.factory import create_shell_provider
from dify_agent.adapters.shell.protocols import (
ShellEnvironmentDescriptor,
ShellExecutionResult,
ShellExecutorProtocol,
CompleteShellCommandResult,
ShellCommandProtocol,
ShellCommandResult,
ShellCommandStatus,
ShellFileTransferProtocol,
ShellHandle,
ShellProvisionProtocol,
)
from dify_agent.adapters.shell.shellctl import (
ShellctlEnvironmentDescriptor,
ShellctlProvisioner,
ShellFileTransferError,
ShellProvisionError,
create_default_shellctl_client_factory,
ShellPromptObservation,
ShellProviderError,
ShellProviderProtocol,
ShellResourceProtocol,
)
__all__ = [
"CompleteShellCommandResult",
"DEFAULT_SHELL_PROVIDER",
"ShellAdapterSettings",
"ShellEnvironmentDescriptor",
"ShellExecutionResult",
"ShellExecutorProtocol",
"ShellFileTransferError",
"ShellCommandProtocol",
"ShellCommandResult",
"ShellCommandStatus",
"ShellFileTransferProtocol",
"ShellHandle",
"ShellProvisionError",
"ShellProvisionProtocol",
"ShellctlEnvironmentDescriptor",
"ShellctlProvisioner",
"create_default_shellctl_client_factory",
"create_shell_provisioner",
"ShellPromptObservation",
"ShellProviderError",
"ShellProviderProtocol",
"ShellResourceProtocol",
"create_shell_provider",
]
@@ -6,7 +6,7 @@ DEFAULT_SHELL_PROVIDER = "shellctl"
class ShellAdapterSettings(BaseSettings):
"""Env-backed settings used to construct a shell provisioner.
"""Env-backed settings used to construct a shell provider.
``shellctl_auth_token`` defaults to ``None``; the factory forwards an empty
string to the shellctl client so it does not fall back to ambient process
@@ -1,21 +1,10 @@
from dify_agent.adapters.shell.config import ShellAdapterSettings
from dify_agent.adapters.shell.protocols import ShellProvisionProtocol
from dify_agent.adapters.shell.shellctl import (
ShellctlEnvironmentDescriptor,
ShellctlProvisioner,
create_default_shellctl_client_factory,
)
from dify_agent.adapters.shell.protocols import ShellProviderProtocol
from dify_agent.adapters.shell.shellctl import ShellctlProvider
def create_shell_provisioner(
settings: ShellAdapterSettings | None = None,
) -> ShellProvisionProtocol[ShellctlEnvironmentDescriptor]:
"""Return the shell provisioner selected by ``DIFY_AGENT_SHELL_PROVIDER``.
Raises:
ValueError: if the provider name is unknown, or if the ``shellctl``
provider is selected without a non-empty ``DIFY_AGENT_SHELLCTL_ENTRYPOINT``.
"""
def create_shell_provider(settings: ShellAdapterSettings | None = None) -> ShellProviderProtocol:
"""Return the shell provider selected by ``DIFY_AGENT_SHELL_PROVIDER``."""
resolved = settings or ShellAdapterSettings()
provider = resolved.shell_provider.strip().lower()
match provider:
@@ -23,14 +12,12 @@ def create_shell_provisioner(
entrypoint = (resolved.shellctl_entrypoint or "").strip()
if not entrypoint:
raise ValueError("DIFY_AGENT_SHELLCTL_ENTRYPOINT is required for the 'shellctl' shell provider.")
return ShellctlProvisioner(
client_factory=create_default_shellctl_client_factory(
entrypoint=entrypoint,
token=resolved.shellctl_auth_token or "",
),
return ShellctlProvider(
entrypoint=entrypoint,
token=resolved.shellctl_auth_token or "",
)
case _:
raise ValueError(f"Unknown shell provider: {resolved.shell_provider!r}.")
__all__ = ["create_shell_provisioner"]
__all__ = ["create_shell_provider"]
@@ -1,128 +1,125 @@
from typing import Protocol, TypeVar
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, model_validator
from typing_extensions import Self
from dataclasses import dataclass
from typing import Literal, Protocol
class ShellEnvironmentDescriptor(BaseModel):
"""Minimal, serializable seed used to re-derive a provisioned environment.
Holds only the provider-agnostic identity needed to reattach to an existing
shell environment across a snapshot/resume cycle never live resources
(clients, handles, executors). Callers persist this in their snapshot and
pass it back to ``ShellProvisionProtocol.reattach`` to reconstruct an
equivalent ``ShellHandle`` without allocating a new environment.
Each provider defines its own concrete subclass carrying the fields it needs
to reattach (e.g. workspace path + session id for shellctl). Validation runs
at instantiation time via Pydantic so a malicious or corrupt snapshot cannot
escape the workspace root or inject shell syntax into lifecycle commands,
even if a future caller uses the provider directly.
"""
model_config = ConfigDict(extra="forbid", frozen=True)
@model_validator(mode="after")
def _run_validate(self) -> Self:
self.validate()
return self
def validate(self) -> None:
"""validate the correctness of the object.
Advanced validations that requires remote procedure calls,
for example access control, quota checks, should be implemented in
provision and reattach.
"""
raise NotImplementedError
@dataclass(frozen=True, slots=True)
class ShellCommandResult:
job_id: str
status: str
done: bool
exit_code: int | None
output: str
offset: int
truncated: bool
output_path: str | None = None
DescriptorT = TypeVar("DescriptorT", bound=ShellEnvironmentDescriptor)
@dataclass(frozen=True, slots=True)
class ShellCommandStatus:
job_id: str
status: str
done: bool
exit_code: int | None
offset: int
class ShellExecutionResult(Protocol):
"""Completed shell command result.
``stdout``/``stderr``/``exit_code`` are reserved fields. Backends that
cannot distinguish a stream return an empty string for it, and return
``None`` from ``exit_code()`` when no exit status is available.
"""
def stdout(self) -> str: ...
def stderr(self) -> str: ...
def exit_code(self) -> int | None: ...
def truncated(self) -> bool: ...
@dataclass(frozen=True, slots=True)
class CompleteShellCommandResult:
job_id: str
status: str
done: bool
exit_code: int | None
output: str
output_complete: bool
incomplete_reason: Literal["output_limit", "timeout"] | None
offset: int
output_path: str | None = None
class ShellExecutorProtocol(Protocol):
"""Runs commands inside an already-provisioned shell environment.
@dataclass(frozen=True, slots=True)
class ShellPromptObservation:
text: str
output_path: str | None
offset: int
``execute`` drains the command to completion before returning there is no
separate ``wait`` step. This suits the current server-side callers (sandbox
file helpers, workspace bootstrap) that always run a script to completion.
If a future use case needs to start a command, interact with its stdin, or
interrupt it before completion, split this protocol into ``execute``
``ShellExecutionHandle`` plus ``wait`` / ``input`` / ``interrupt`` optional
capabilities, mirroring the shape that was prototyped here before
simplification.
"""
class ShellProviderError(RuntimeError):
code: str | None
async def execute(
def __init__(self, message: str, *, code: str | None = None) -> None:
super().__init__(message)
self.code = code
class ShellCommandProtocol(Protocol):
async def run(
self,
command: str,
script: str,
*,
cwd: str | None = None,
env: dict[str, str] | None = None,
) -> ShellExecutionResult: ...
timeout: float,
) -> ShellCommandResult: ...
async def wait(
self,
job_id: str,
*,
offset: int,
timeout: float,
) -> ShellCommandResult: ...
async def read_output(
self,
job_id: str,
*,
offset: int,
) -> ShellCommandResult: ...
async def input(
self,
job_id: str,
text: str,
*,
offset: int,
timeout: float,
) -> ShellCommandResult: ...
async def interrupt(
self,
job_id: str,
*,
grace_seconds: float,
) -> ShellCommandStatus: ...
async def tail(self, job_id: str) -> ShellCommandResult: ...
async def delete(
self,
job_id: str,
*,
force: bool = False,
grace_seconds: float | None = None,
) -> None: ...
class ShellFileTransferProtocol(Protocol):
"""Moves file bytes between the caller and a provisioned shell environment.
async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None: ...
``remote_path`` is interpreted by the backend relative to the provisioned
environment (for the shellctl backend, the session workspace). Higher-level
Dify/skill transfers are layered on top of this primitive, not implemented
here. Implementations raise on transfer failure (missing path, decode error,
or a non-zero transfer command).
"""
async def upload(self, *, content: bytes, remote_path: str) -> None: ...
async def download(self, *, remote_path: str) -> bytes: ...
async def download(self, *, remote_path: str, cwd: str | None = None) -> bytes: ...
# pyrefly: ignore [variance-mismatch]
# intended to be invariant
class ShellHandle(Protocol[DescriptorT]):
"""Live reference to one provisioned shell environment.
class ShellResourceProtocol(Protocol):
@property
def commands(self) -> ShellCommandProtocol: ...
The handle itself is not serialized. ``descriptor()`` returns the minimal
seed needed to reconstruct an equivalent handle after a snapshot/resume.
"""
@property
def files(self) -> ShellFileTransferProtocol: ...
def descriptor(self) -> DescriptorT: ...
async def get_executor(self) -> ShellExecutorProtocol: ...
async def get_file_transfer(self) -> ShellFileTransferProtocol: ...
async def close(self) -> None: ...
class ShellProvisionProtocol(Protocol[DescriptorT]):
"""Creates, reattaches to, and destroys shell environments.
``provision`` allocates a fresh environment; ``reattach`` rebuilds a live
handle for an environment that already exists (from a persisted descriptor)
without allocating a new one, so resumed runs can keep executing and
eventually clean up. ``destroy`` tears an environment down.
"""
async def provision(self) -> ShellHandle[DescriptorT]: ...
async def reattach(self, descriptor: DescriptorT) -> ShellHandle[DescriptorT]: ...
async def destroy(self, handle: ShellHandle[DescriptorT]) -> None: ...
class ShellProviderProtocol(Protocol):
async def create(self) -> ShellResourceProtocol: ...
@@ -4,87 +4,60 @@ import base64
import binascii
import logging
import re
import secrets
import time
from collections.abc import Awaitable
from collections.abc import Callable
from dataclasses import dataclass
from typing import Protocol
from typing import Protocol, TypeVar
from dify_agent.adapters.shell.protocols import ShellEnvironmentDescriptor, ShellHandle
from dify_agent.adapters.shell.protocols import (
ShellCommandProtocol,
ShellCommandResult,
ShellCommandStatus,
ShellFileTransferProtocol,
ShellProviderError,
ShellProviderProtocol,
ShellResourceProtocol,
)
logger = logging.getLogger(__name__)
_WORKSPACE_ROOT = "~/workspace"
ResultT = TypeVar("ResultT")
_DEFAULT_TIMEOUT_SECONDS = 30.0
_SESSION_ID_PATTERN = re.compile(r"[0-9a-f]{7,16}")
# Drains at most this many shellctl output windows per wait so a stuck or
# pathologically chatty job cannot loop forever inside one wait() call.
_MAX_OUTPUT_WINDOWS = 64
_READ_OUTPUT_TIMEOUT_SECONDS = 0.0
_DEFAULT_TERMINATE_GRACE_SECONDS = 10.0
_FILE_TRANSFER_TIMEOUT_SECONDS = 60.0
# Sentinels frame base64 download payloads so prompt/tmux noise around the
# shellctl merged output stream can be stripped before decoding.
_SHELLCTL_OUTPUT_LIMIT_BYTES = 16 * 1024
_TRANSFER_BEGIN = "<<<DIFY_SHELL_FILE_BEGIN>>>"
_TRANSFER_END = "<<<DIFY_SHELL_FILE_END>>>"
_DOWNLOAD_MISSING_EXIT_CODE = 66
class ShellProvisionError(RuntimeError):
"""Raised when a shell environment cannot be provisioned."""
class ShellFileTransferError(RuntimeError):
"""Raised when a file cannot be uploaded to or downloaded from the workspace."""
class ShellctlEnvironmentDescriptor(ShellEnvironmentDescriptor):
"""Shellctl-specific descriptor carrying the workspace path and session id."""
workspace_cwd: str
session_id: str
def validate(self) -> None:
if not _SESSION_ID_PATTERN.fullmatch(self.session_id):
raise ValueError(f"Invalid session_id in reattach descriptor: {self.session_id!r}.")
expected_workspace = f"{_WORKSPACE_ROOT}/{self.session_id}"
if self.workspace_cwd != expected_workspace:
raise ValueError(
f"workspace_cwd must equal {expected_workspace!r} for session_id {self.session_id!r}, "
f"got {self.workspace_cwd!r}."
)
"""Raised when a file cannot be uploaded or downloaded through shellctl."""
class ShellctlJobResult(Protocol):
"""Structural shape of one shellctl job result the adapter relies on.
Mirrors the fields the adapter reads from ``shell_session_manager`` job
results without importing the concrete type, so the merged output stream,
paging offset, completion flag, and exit status stay duck-typed.
"""
job_id: str
status: object
done: bool
output: str
offset: int
truncated: bool
exit_code: int | None
output_path: str | None
class ShellctlJobStatus(Protocol):
"""Structural shape of one shellctl status-only result (no output stream).
Returned by ``terminate``; carries completion and exit status plus the
latest paging offset so the adapter can drain any remaining output.
"""
job_id: str
status: object
done: bool
offset: int
exit_code: int | None
class ShellctlClientProtocol(Protocol):
"""Boundary the shellctl adapter needs from a shell-session-manager client."""
async def run(
self,
script: str,
@@ -111,13 +84,21 @@ class ShellctlClientProtocol(Protocol):
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
) -> ShellctlJobResult: ...
async def tail(self, job_id: str) -> ShellctlJobResult: ...
async def terminate(
self,
job_id: str,
grace_seconds: float = _DEFAULT_TERMINATE_GRACE_SECONDS,
) -> ShellctlJobStatus: ...
async def delete(self, job_id: str, *, force: bool = False) -> object: ...
async def delete(
self,
job_id: str,
*,
force: bool = False,
grace_seconds: float | None = None,
) -> object: ...
async def close(self) -> None: ...
@@ -125,127 +106,86 @@ class ShellctlClientProtocol(Protocol):
type ShellctlClientFactory = Callable[[], ShellctlClientProtocol]
class ShellctlExecutionResult:
"""Completed shellctl command result.
shellctl merges stderr into a single output stream, so ``stderr()`` is
always empty and the merged stream is reported as ``stdout()``.
"""
_stdout: str
_stderr: str
_exit_code: int | None
_truncated: bool
def __init__(
self,
*,
stdout: str,
stderr: str = "",
exit_code: int | None,
truncated: bool = False,
) -> None:
self._stdout = stdout
self._stderr = stderr
self._exit_code = exit_code
self._truncated = truncated
def stdout(self) -> str:
return self._stdout
def stderr(self) -> str:
return self._stderr
def exit_code(self) -> int | None:
return self._exit_code
def truncated(self) -> bool:
"""Whether the returned ``stdout`` may be incomplete.
``True`` means shellctl still reported more output past what was
captured when draining stopped (its per-window ``truncated`` flag on the
final window, e.g. the output-window cap was hit). Callers that need the
command's *entire* output must treat a truncated result as a failure or
re-read, rather than trusting ``stdout()`` as complete.
"""
return self._truncated
@dataclass(slots=True)
class ShellctlExecutor:
"""Runs commands in one provisioned shellctl workspace.
Conforms structurally to ``ShellExecutorProtocol``. ``execute`` drains the
command to completion (accumulating shellctl's paged output windows) and
best-effort deletes the finished job before returning. The executor is
single-environment and is not safe to share across workspaces.
"""
class ShellctlCommands(ShellCommandProtocol):
client: ShellctlClientProtocol
workspace_cwd: str
timeout: float = _DEFAULT_TIMEOUT_SECONDS
async def execute(
async def run(
self,
command: str,
script: str,
*,
cwd: str | None = None,
env: dict[str, str] | None = None,
) -> ShellctlExecutionResult:
result = await self.client.run(
command,
cwd=cwd if cwd is not None else self.workspace_cwd,
env=env,
timeout=self.timeout,
)
output_parts = [result.output]
done = result.done
truncated = result.truncated
offset = result.offset
exit_code = result.exit_code
job_id = result.job_id
windows = 1
while (not done or truncated) and windows < _MAX_OUTPUT_WINDOWS:
result = await self.client.wait(job_id, offset=offset, timeout=self.timeout)
output_parts.append(result.output)
done = result.done
truncated = result.truncated
offset = result.offset
exit_code = result.exit_code
windows += 1
if done:
await _delete_job_best_effort(self.client, job_id)
return ShellctlExecutionResult(
stdout="".join(output_parts),
exit_code=exit_code,
truncated=truncated,
timeout: float,
) -> ShellCommandResult:
return _from_job_result(await _run_client_call(self.client.run(script, cwd=cwd, env=env, timeout=timeout)))
async def wait(
self,
job_id: str,
*,
offset: int,
timeout: float,
) -> ShellCommandResult:
return _from_job_result(await _run_client_call(self.client.wait(job_id, offset=offset, timeout=timeout)))
async def read_output(
self,
job_id: str,
*,
offset: int,
) -> ShellCommandResult:
return _from_job_result(
await _run_client_call(self.client.wait(job_id, offset=offset, timeout=_READ_OUTPUT_TIMEOUT_SECONDS))
)
async def input(
self,
job_id: str,
text: str,
*,
offset: int,
timeout: float,
) -> ShellCommandResult:
return _from_job_result(await _run_client_call(self.client.input(job_id, text, offset=offset, timeout=timeout)))
async def interrupt(
self,
job_id: str,
*,
grace_seconds: float,
) -> ShellCommandStatus:
return _from_job_status(await _run_client_call(self.client.terminate(job_id, grace_seconds=grace_seconds)))
async def tail(self, job_id: str) -> ShellCommandResult:
return _from_job_result(await _run_client_call(self.client.tail(job_id)))
async def delete(
self,
job_id: str,
*,
force: bool = False,
grace_seconds: float | None = None,
) -> None:
try:
_ = await self.client.delete(job_id, force=force, grace_seconds=grace_seconds)
except RuntimeError as exc:
if getattr(exc, "code", None) == "job_not_found":
return
raise _map_error(exc) from exc
@dataclass(slots=True)
class ShellctlFileTransfer:
"""Moves file bytes in and out of one provisioned shellctl workspace.
Conforms structurally to ``ShellFileTransferProtocol``. Transfers run as
workspace-scoped shellctl jobs over the merged text channel: uploads embed
base64 in the command and pipe it through ``base64 -d``; downloads emit the
file's base64 framed by sentinels so prompt/tmux noise can be stripped
before decoding. Because the encoded payload is embedded in the upload
command, very large files can exceed the shell argument limit; this
primitive targets ordinary control-plane file sizes, not bulk binary
transfer.
"""
class ShellctlFileTransfer(ShellFileTransferProtocol):
client: ShellctlClientProtocol
workspace_cwd: str
timeout: float = _FILE_TRANSFER_TIMEOUT_SECONDS
async def upload(self, *, content: bytes, remote_path: str) -> None:
async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None:
encoded = base64.b64encode(content).decode("ascii")
completed = await _run_to_completion(
self.client,
_upload_script(remote_path=remote_path, encoded=encoded),
cwd=self.workspace_cwd,
cwd=cwd,
timeout=self.timeout,
)
if completed.exit_code != 0:
@@ -254,133 +194,126 @@ class ShellctlFileTransfer:
f"output={_output_tail(completed.output)!r}"
)
async def download(self, *, remote_path: str) -> bytes:
async def download(self, *, remote_path: str, cwd: str | None = None) -> bytes:
completed = await _run_to_completion(
self.client,
_download_script(remote_path=remote_path),
cwd=self.workspace_cwd,
cwd=cwd,
timeout=self.timeout,
)
if completed.exit_code == _DOWNLOAD_MISSING_EXIT_CODE:
raise ShellFileTransferError(f"File not found in workspace: {remote_path!r}.")
raise ShellFileTransferError(f"Remote path not found: {remote_path!r}.")
if completed.exit_code != 0:
raise ShellFileTransferError(
f"Failed to download {remote_path!r}: exit_code={completed.exit_code}, "
f"output={_output_tail(completed.output)!r}"
)
framed = _extract_framed_payload(completed.output)
encoded = _extract_transfer_payload(completed.output)
try:
return base64.b64decode("".join(framed.split()), validate=True)
except (binascii.Error, ValueError) as exc:
raise ShellFileTransferError(f"Failed to decode downloaded file {remote_path!r}: {exc}") from exc
return base64.b64decode(encoded.encode("ascii"), validate=True)
except (ValueError, binascii.Error) as exc:
raise ShellFileTransferError(f"Downloaded payload for {remote_path!r} was not valid base64.") from exc
@dataclass(slots=True)
class ShellctlHandle:
"""Live reference to one provisioned shellctl workspace.
Conforms structurally to ``ShellHandle``. Owns the shellctl ``client`` and
the allocated ``workspace_cwd`` until the provisioner destroys it.
``get_executor`` returns a fresh executor bound to this workspace each call.
"""
class ShellctlResource(ShellResourceProtocol):
client: ShellctlClientProtocol
workspace_cwd: str
session_id: str
commands: ShellCommandProtocol
files: ShellFileTransferProtocol
def descriptor(self) -> ShellctlEnvironmentDescriptor:
return ShellctlEnvironmentDescriptor(workspace_cwd=self.workspace_cwd, session_id=self.session_id)
async def get_executor(self) -> ShellctlExecutor:
return ShellctlExecutor(client=self.client, workspace_cwd=self.workspace_cwd)
async def get_file_transfer(self) -> ShellctlFileTransfer:
return ShellctlFileTransfer(client=self.client, workspace_cwd=self.workspace_cwd)
async def close(self) -> None:
try:
await self.client.close()
except RuntimeError as exc:
raise _map_error(exc) from exc
@dataclass(slots=True)
class ShellctlProvisioner:
"""Provisions isolated shellctl workspaces, one client per environment.
class ShellctlProvider(ShellProviderProtocol):
entrypoint: str
token: str
output_limit: int = _SHELLCTL_OUTPUT_LIMIT_BYTES
client_factory: ShellctlClientFactory | None = None
Conforms structurally to ``ShellProvisionProtocol``.
"""
client_factory: ShellctlClientFactory
timeout: float = _DEFAULT_TIMEOUT_SECONDS
async def provision(self) -> ShellctlHandle:
client = self.client_factory()
session_id = _generate_session_id()
workspace_cwd = f"{_WORKSPACE_ROOT}/{session_id}"
try:
completed = await _run_to_completion(client, _mkdir_script(session_id), cwd=None, timeout=self.timeout)
except BaseException:
await client.close()
raise
if completed.exit_code != 0:
await client.close()
raise ShellProvisionError(
f"Failed to create shell workspace {workspace_cwd}: mkdir exited with code {completed.exit_code}."
)
return ShellctlHandle(client=client, workspace_cwd=workspace_cwd, session_id=session_id)
async def reattach(self, descriptor: ShellctlEnvironmentDescriptor) -> ShellctlHandle:
"""Rebuild a live handle for an existing workspace without re-allocating it.
Opens a fresh shellctl client and points it at the workspace recorded in
``descriptor``. No ``mkdir`` is issued: the workspace is assumed to still
exist from the original ``provision``. Used on snapshot resume so a run
can keep executing in and eventually clean up its prior workspace.
"""
client = self.client_factory()
return ShellctlHandle(
async def create(self) -> ShellctlResource:
client = (
self.client_factory()
if self.client_factory is not None
else create_default_shellctl_client_factory(
entrypoint=self.entrypoint,
token=self.token,
output_limit=self.output_limit,
)()
)
return ShellctlResource(
client=client,
workspace_cwd=descriptor.workspace_cwd,
session_id=descriptor.session_id,
commands=ShellctlCommands(client=client),
files=ShellctlFileTransfer(client=client),
)
async def destroy(self, handle: ShellHandle[ShellctlEnvironmentDescriptor]) -> None:
if not isinstance(handle, ShellctlHandle):
raise TypeError("ShellctlProvisioner can only destroy handles it provisioned.")
try:
completed = await _run_to_completion(
handle.client, _cleanup_script(handle.session_id), cwd=None, timeout=self.timeout
)
if completed.exit_code != 0:
logger.warning(
"Shell workspace cleanup for session %s exited with code %s.",
handle.session_id,
completed.exit_code,
)
except (RuntimeError, ValueError) as exc:
logger.warning("Failed to remove shell workspace for session %s: %s", handle.session_id, exc)
finally:
await handle.client.close()
def create_default_shellctl_client_factory(*, entrypoint: str, token: str) -> ShellctlClientFactory:
"""Return a factory that builds a real shell-session-manager shellctl client.
The concrete client is imported lazily so importing this module does not
require the private ``shell-session-manager`` package. An explicit empty
``token`` is forwarded as-is to avoid the client falling back to ambient
process credentials.
"""
def create_default_shellctl_client_factory(
*,
entrypoint: str,
token: str,
output_limit: int = _SHELLCTL_OUTPUT_LIMIT_BYTES,
) -> ShellctlClientFactory:
def factory() -> ShellctlClientProtocol:
from shell_session_manager.shellctl.client import ShellctlClient
return ShellctlClient(entrypoint, token=token)
return ShellctlClient(entrypoint, token=token, output_limit=output_limit)
return factory
@dataclass(slots=True)
class _CompletedJob:
"""Drained result of one internal shellctl job: merged output plus exit code."""
output: str
@dataclass(frozen=True, slots=True)
class _CompletedShellctlJob:
job_id: str
exit_code: int | None
output: str
async def _run_client_call(awaitable: Awaitable[ResultT]) -> ResultT:
try:
return await awaitable
except RuntimeError as exc:
raise _map_error(exc) from exc
def _map_error(exc: RuntimeError) -> ShellProviderError:
return ShellProviderError(str(exc), code=getattr(exc, "code", None))
def _from_job_result(result: ShellctlJobResult) -> ShellCommandResult:
return ShellCommandResult(
job_id=result.job_id,
status=_status_name(result.status),
done=result.done,
exit_code=result.exit_code,
output=result.output,
offset=result.offset,
truncated=result.truncated,
output_path=result.output_path or None,
)
def _from_job_status(result: ShellctlJobStatus) -> ShellCommandStatus:
return ShellCommandStatus(
job_id=result.job_id,
status=_status_name(result.status),
done=result.done,
exit_code=result.exit_code,
offset=result.offset,
)
def _status_name(status: object) -> str:
value = getattr(status, "value", None)
if isinstance(value, str):
return value
if isinstance(status, str):
return status
return str(status)
async def _run_to_completion(
@@ -389,98 +322,78 @@ async def _run_to_completion(
*,
cwd: str | None,
timeout: float,
) -> _CompletedJob:
"""Run one internal lifecycle script to completion, returning output and exit code."""
result = await client.run(script, cwd=cwd, env=None, timeout=timeout)
output_parts = [result.output]
done = result.done
truncated = result.truncated
offset = result.offset
exit_code = result.exit_code
job_id = result.job_id
windows = 1
while (not done or truncated) and windows < _MAX_OUTPUT_WINDOWS:
result = await client.wait(job_id, offset=offset, timeout=timeout)
output_parts.append(result.output)
done = result.done
truncated = result.truncated
offset = result.offset
exit_code = result.exit_code
windows += 1
if done:
await _delete_job_best_effort(client, job_id)
return _CompletedJob(output="".join(output_parts), exit_code=exit_code)
async def _delete_job_best_effort(client: ShellctlClientProtocol, job_id: str) -> None:
"""Force-delete one shellctl job, never failing the caller on cleanup errors."""
) -> _CompletedShellctlJob:
deadline = time.monotonic() + timeout
job_id: str | None = None
try:
_ = await client.delete(job_id, force=True)
except Exception as exc: # noqa: BLE001 - best-effort teardown must not surface cleanup errors
logger.warning("Failed to delete shellctl job %s: %s", job_id, exc)
def _generate_session_id() -> str:
"""Return a shell-safe random session id used as the workspace directory name."""
return secrets.token_hex(8)
def _mkdir_script(session_id: str) -> str:
return f'mkdir -p "$HOME/workspace/{session_id}"'
def _cleanup_script(session_id: str) -> str:
return f'rm -rf -- "$HOME/workspace/{session_id}"'
result = await _run_client_call(client.run(script, cwd=cwd, env=None, timeout=_remaining_timeout(deadline)))
parts = [result.output]
job_id = result.job_id
while not result.done or result.truncated:
result = await _run_client_call(
client.wait(job_id, offset=result.offset, timeout=_remaining_timeout(deadline))
)
parts.append(result.output)
return _CompletedShellctlJob(job_id=job_id, exit_code=result.exit_code, output="".join(parts))
finally:
if job_id is not None:
try:
await client.delete(job_id, force=True)
except RuntimeError as exc:
logger.warning("Failed to delete shellctl job %s: %s", job_id, exc)
def _upload_script(*, remote_path: str, encoded: str) -> str:
"""Return a script that recreates a file from embedded base64 in the workspace."""
quoted = _shquote(remote_path)
return f"mkdir -p \"$(dirname -- {quoted})\" && printf %s '{encoded}' | base64 -d > {quoted}"
def _download_script(*, remote_path: str) -> str:
"""Return a script that emits a file's base64 between transfer sentinels."""
quoted = _shquote(remote_path)
return (
f"if [ ! -f {quoted} ]; then exit {_DOWNLOAD_MISSING_EXIT_CODE}; fi; "
f"printf %s {_shquote(_TRANSFER_BEGIN)}; "
f'base64 < {quoted} | tr -d "\\n"; '
f"printf %s {_shquote(_TRANSFER_END)}"
"set -eu\n"
f'mkdir -p "$(dirname -- {_shquote(remote_path)})"\n'
f"printf %s {_shquote(encoded)} | base64 -d > {_shquote(remote_path)}"
)
def _extract_framed_payload(output: str) -> str:
"""Return the base64 text framed by the transfer sentinels in shellctl output."""
begin = output.find(_TRANSFER_BEGIN)
end = output.find(_TRANSFER_END, begin + len(_TRANSFER_BEGIN)) if begin != -1 else -1
if begin == -1 or end == -1:
raise ShellFileTransferError("download command returned no framed payload")
return output[begin + len(_TRANSFER_BEGIN) : end]
def _download_script(*, remote_path: str) -> str:
return "\n".join(
[
"set -eu",
f"path={_shquote(remote_path)}",
'if [ ! -f "$path" ]; then exit 66; fi',
f"printf %s {_shquote(_TRANSFER_BEGIN)}",
'base64 < "$path" | tr -d "\\n"',
f"printf %s {_shquote(_TRANSFER_END)}",
]
)
def _extract_transfer_payload(output: str) -> str:
pattern = re.escape(_TRANSFER_BEGIN) + r"(.*?)" + re.escape(_TRANSFER_END)
match = re.search(pattern, output, re.DOTALL)
if match is None:
raise ShellFileTransferError("Transfer payload markers were missing from shell output.")
return "".join(match.group(1).split())
def _output_tail(output: str, *, limit: int = 256) -> str:
return output[-limit:]
def _remaining_timeout(deadline: float) -> float:
remaining = deadline - time.monotonic()
if remaining <= 0.0:
raise ShellProviderError("Shellctl command timed out before completion.", code="timeout")
return remaining
def _shquote(value: str) -> str:
"""Single-quote a value for POSIX shells, escaping embedded single quotes."""
return "'" + value.replace("'", "'\\''") + "'"
def _output_tail(output: str, *, limit: int = 500) -> str:
"""Return the trailing slice of command output for compact error messages."""
return output[-limit:]
__all__ = [
"ShellFileTransferError",
"ShellProvisionError",
"ShellctlClientFactory",
"ShellctlClientProtocol",
"ShellctlEnvironmentDescriptor",
"ShellctlExecutionResult",
"ShellctlExecutor",
"ShellctlCommands",
"ShellctlFileTransfer",
"ShellctlHandle",
"ShellctlJobResult",
"ShellctlJobStatus",
"ShellctlProvisioner",
"ShellctlProvider",
"ShellctlResource",
"create_default_shellctl_client_factory",
]
@@ -92,12 +92,12 @@ def resolve_drive_destination(base_path: Path, drive_key: str) -> Path:
return destination
def extract_skill_archive(archive_path: Path) -> None:
"""Safely extract one downloaded skill archive into its containing directory."""
def extract_archive_to_directory(archive_path: Path, *, target_dir: Path) -> None:
"""Safely extract one downloaded archive into one resolved target directory."""
target_dir = archive_path.parent.resolve()
resolved_target_dir = target_dir.resolve()
try:
with TemporaryDirectory(dir=target_dir, prefix=".dify-skill-extract-") as staging_dir_name:
with TemporaryDirectory(dir=resolved_target_dir, prefix=".dify-skill-extract-") as staging_dir_name:
staging_dir = Path(staging_dir_name).resolve()
with ZipFile(archive_path) as archive:
for zip_info in archive.infolist():
@@ -118,7 +118,7 @@ def extract_skill_archive(archive_path: Path) -> None:
if staged_path.is_dir():
continue
relative_path = staged_path.relative_to(staging_dir)
destination = (target_dir / relative_path).resolve()
destination = (resolved_target_dir / relative_path).resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
_ = staged_path.replace(destination)
except DriveMaterializationValidationError:
@@ -127,6 +127,12 @@ def extract_skill_archive(archive_path: Path) -> None:
raise DriveMaterializationTransferError(f"downloaded skill archive is invalid: {archive_path.name}") from exc
def extract_skill_archive(archive_path: Path) -> None:
"""Safely extract one downloaded skill archive into its containing directory."""
extract_archive_to_directory(archive_path, target_dir=archive_path.parent.resolve())
def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path:
normalized_name = entry_name.replace("\\", "/")
pure_path = PurePosixPath(normalized_name)
@@ -163,6 +169,7 @@ __all__ = [
"DriveMaterializationTransferError",
"DriveMaterializationValidationError",
"SKILL_ARCHIVE_FILENAME",
"extract_archive_to_directory",
"extract_skill_archive",
"materialize_drive_downloads",
"resolve_drive_destination",
@@ -0,0 +1,320 @@
"""CLI helpers for sandbox-visible Agent Stub config commands."""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
from pydantic import BaseModel, ConfigDict
from dify_agent.agent_stub._drive_materialization import (
DriveMaterializationTransferError,
DriveMaterializationValidationError,
extract_archive_to_directory,
)
from dify_agent.agent_stub.cli._drive import _build_skill_archive
from dify_agent.agent_stub.cli._env import read_agent_stub_environment
from dify_agent.agent_stub.cli._files import upload_tool_file_resource_from_environment
from dify_agent.agent_stub.client._agent_stub import (
request_agent_stub_config_manifest_sync,
request_agent_stub_config_push_sync,
request_agent_stub_config_file_pull_sync,
request_agent_stub_config_skill_pull_sync,
)
from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConfigFileRef,
AgentStubConfigManifestResponse,
AgentStubConfigPushFileItem,
AgentStubConfigPushRequest,
AgentStubConfigPushSkillItem,
)
_DEFAULT_CONFIG_BASE = Path("./.dify_conf")
_SKILL_MD_FILENAME = "SKILL.md"
_SAFE_ENV_VALUE = re.compile(r"^[A-Za-z0-9_./:@+-]+$")
class ConfigSkillPullResult(BaseModel):
class Item(BaseModel):
name: str
archive_path: str
directory_path: str
skill_md: str
model_config = ConfigDict(extra="forbid")
items: list[Item]
model_config = ConfigDict(extra="forbid")
class ConfigFilePullResult(BaseModel):
class Item(BaseModel):
name: str
path: str
model_config = ConfigDict(extra="forbid")
items: list[Item]
model_config = ConfigDict(extra="forbid")
@dataclass(frozen=True, slots=True)
class _PreparedPushItem:
name: str
path: Path
def manifest_from_environment() -> AgentStubConfigManifestResponse:
environment = read_agent_stub_environment()
return request_agent_stub_config_manifest_sync(url=environment.url, auth_jwe=environment.auth_jwe)
def pull_config_skills_from_environment(
names: list[str] | None = None,
local_dir: str | None = None,
) -> ConfigSkillPullResult:
environment = read_agent_stub_environment()
manifest = request_agent_stub_config_manifest_sync(url=environment.url, auth_jwe=environment.auth_jwe)
selected_names = names or [item.name for item in manifest.skills.items]
target_dir = Path(local_dir or (_DEFAULT_CONFIG_BASE / "skills")).expanduser().resolve()
target_dir.mkdir(parents=True, exist_ok=True)
items: list[ConfigSkillPullResult.Item] = []
for name in selected_names:
archive_bytes = request_agent_stub_config_skill_pull_sync(
url=environment.url,
auth_jwe=environment.auth_jwe,
name=name,
)
archive_path = target_dir / f"{name}.zip"
skill_dir = target_dir / name
skill_dir.mkdir(parents=True, exist_ok=True)
archive_path.write_bytes(archive_bytes)
try:
extract_archive_to_directory(archive_path, target_dir=skill_dir)
except DriveMaterializationValidationError as exc:
raise AgentStubValidationError(str(exc)) from exc
except DriveMaterializationTransferError as exc:
raise AgentStubTransferError(str(exc)) from exc
skill_md_path = skill_dir / _SKILL_MD_FILENAME
if not skill_md_path.is_file():
raise AgentStubValidationError(f"pulled config skill is missing {_SKILL_MD_FILENAME}: {name}")
items.append(
ConfigSkillPullResult.Item(
name=name,
archive_path=str(archive_path),
directory_path=str(skill_dir),
skill_md=skill_md_path.read_text(encoding="utf-8"),
)
)
return ConfigSkillPullResult(items=items)
def pull_config_files_from_environment(
names: list[str] | None = None,
local_dir: str | None = None,
) -> ConfigFilePullResult:
environment = read_agent_stub_environment()
manifest = request_agent_stub_config_manifest_sync(url=environment.url, auth_jwe=environment.auth_jwe)
selected_names = names or [item.name for item in manifest.files.items]
target_dir = Path(local_dir or (_DEFAULT_CONFIG_BASE / "files")).expanduser().resolve()
target_dir.mkdir(parents=True, exist_ok=True)
items: list[ConfigFilePullResult.Item] = []
for name in selected_names:
payload = request_agent_stub_config_file_pull_sync(
url=environment.url,
auth_jwe=environment.auth_jwe,
name=name,
)
target_path = target_dir / name
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(payload)
items.append(ConfigFilePullResult.Item(name=name, path=str(target_path)))
return ConfigFilePullResult(items=items)
def pull_config_env_from_environment(local_path: str | None = None) -> Path:
manifest = manifest_from_environment()
target_path = Path(local_path or (_DEFAULT_CONFIG_BASE / ".env")).expanduser().resolve()
target_path.parent.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
for key in manifest.env_keys:
if key not in os.environ:
raise AgentStubValidationError(f"config env key is not present in the current environment: {key}")
lines.append(f"{key}={_format_env_value(os.environ[key])}")
target_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
return target_path
def pull_config_note_from_environment(local_path: str | None = None) -> Path:
manifest = manifest_from_environment()
target_path = Path(local_path or (_DEFAULT_CONFIG_BASE / "note.md")).expanduser().resolve()
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(manifest.note, encoding="utf-8")
return target_path
def push_config_note_from_environment(local_path: str | None) -> AgentStubConfigManifestResponse:
note = _read_text_input(local_path, _DEFAULT_CONFIG_BASE / "note.md")
return _push_config_from_environment(note=note)
def push_config_env_from_environment(local_path: str | None) -> AgentStubConfigManifestResponse:
env_text = _read_text_input(local_path, _DEFAULT_CONFIG_BASE / ".env")
return _push_config_from_environment(env_text=env_text)
def push_config_files_from_environment(paths: list[str], name: str | None) -> AgentStubConfigManifestResponse:
_require_non_empty_inputs(paths, kind="file path")
override_name = None if name is None else _require_config_entry_name(name, kind="file")
if override_name is not None and len(paths) != 1:
raise AgentStubValidationError("--name requires exactly one PATH")
items = [
_PreparedPushItem(
name=override_name if index == 0 and override_name is not None else _infer_name_from_path(source_path),
path=source_path,
)
for index, source_path in enumerate(_resolve_input_paths(paths))
]
return _push_config_from_environment(files=[_build_file_push_item(item=item) for item in items])
def delete_config_files_from_environment(names: list[str]) -> AgentStubConfigManifestResponse:
_require_non_empty_inputs(names, kind="file name")
return _push_config_from_environment(
files=[
AgentStubConfigPushFileItem(name=_require_config_entry_name(name, kind="file"), file_ref=None)
for name in names
]
)
def push_config_skills_from_environment(paths: list[str]) -> AgentStubConfigManifestResponse:
_require_non_empty_inputs(paths, kind="skill directory")
items = [
_PreparedPushItem(name=_infer_name_from_path(source_path), path=source_path)
for source_path in _resolve_input_paths(paths)
]
return _push_config_from_environment(skills=[_build_skill_push_item(item=item) for item in items])
def delete_config_skills_from_environment(names: list[str]) -> AgentStubConfigManifestResponse:
_require_non_empty_inputs(names, kind="skill name")
return _push_config_from_environment(
skills=[
AgentStubConfigPushSkillItem(name=_require_config_entry_name(name, kind="skill"), file_ref=None)
for name in names
]
)
def _push_config_from_environment(
*,
files: list[AgentStubConfigPushFileItem] | None = None,
skills: list[AgentStubConfigPushSkillItem] | None = None,
env_text: str | None = None,
note: str | None = None,
) -> AgentStubConfigManifestResponse:
environment = read_agent_stub_environment()
return request_agent_stub_config_push_sync(
url=environment.url,
auth_jwe=environment.auth_jwe,
request=AgentStubConfigPushRequest(
files=files or [],
skills=skills or [],
env_text=env_text,
note=note,
),
)
def _read_text_input(path_value: str | None, default_path: Path) -> str:
if path_value == "-":
return os.fdopen(os.dup(0), encoding="utf-8").read()
source_path = Path(path_value or default_path).expanduser().resolve()
if not source_path.is_file():
raise AgentStubValidationError(f"local file not found: {source_path}")
return source_path.read_text(encoding="utf-8")
def _resolve_input_paths(paths: list[str]) -> list[Path]:
return [Path(path).expanduser().resolve() for path in paths]
def _infer_name_from_path(path: Path) -> str:
return path.name
def _build_file_push_item(
*,
item: _PreparedPushItem,
) -> AgentStubConfigPushFileItem:
if not item.path.is_file():
raise AgentStubValidationError(f"config file path must be a regular file: {item.path}")
uploaded = upload_tool_file_resource_from_environment(path=str(item.path))
return AgentStubConfigPushFileItem(
name=item.name,
file_ref=AgentStubConfigFileRef(kind="tool_file", id=uploaded.tool_file_id),
)
def _build_skill_push_item(
*,
item: _PreparedPushItem,
) -> AgentStubConfigPushSkillItem:
if not item.path.is_dir():
raise AgentStubValidationError(f"config skill path must be a directory: {item.path}")
skill_md_path = item.path / _SKILL_MD_FILENAME
if not skill_md_path.is_file():
raise AgentStubValidationError(f"config skill directory must contain {_SKILL_MD_FILENAME}: {item.path}")
with TemporaryDirectory() as temp_dir:
archive_path = Path(temp_dir) / f"{item.name}.zip"
_build_skill_archive(item.path, archive_path)
uploaded = upload_tool_file_resource_from_environment(path=str(archive_path))
return AgentStubConfigPushSkillItem(
name=item.name,
file_ref=AgentStubConfigFileRef(kind="tool_file", id=uploaded.tool_file_id),
)
def _require_config_entry_name(name: str, *, kind: str) -> str:
normalized = name.strip()
if not normalized:
raise AgentStubValidationError(f"config {kind} name must not be empty")
return normalized
def _require_non_empty_inputs(values: list[str], *, kind: str) -> None:
if not values:
raise AgentStubValidationError(f"at least one {kind} is required")
def _format_env_value(value: str) -> str:
if _SAFE_ENV_VALUE.fullmatch(value):
return value
return json.dumps(value)
__all__ = [
"ConfigFilePullResult",
"ConfigSkillPullResult",
"manifest_from_environment",
"pull_config_env_from_environment",
"pull_config_files_from_environment",
"pull_config_note_from_environment",
"pull_config_skills_from_environment",
"push_config_env_from_environment",
"push_config_files_from_environment",
"push_config_note_from_environment",
"push_config_skills_from_environment",
"delete_config_files_from_environment",
"delete_config_skills_from_environment",
]
@@ -13,10 +13,24 @@ from __future__ import annotations
import sys
from typing import cast
import click
import typer
from typer.main import get_command
from dify_agent.agent_stub.cli._agent_stub import connect_from_environment
from dify_agent.agent_stub.cli._config import (
delete_config_files_from_environment,
delete_config_skills_from_environment,
manifest_from_environment,
pull_config_env_from_environment,
pull_config_files_from_environment,
pull_config_note_from_environment,
pull_config_skills_from_environment,
push_config_env_from_environment,
push_config_files_from_environment,
push_config_note_from_environment,
push_config_skills_from_environment,
)
from dify_agent.agent_stub.cli._drive import (
DrivePushKind,
format_drive_manifest,
@@ -33,17 +47,45 @@ from dify_agent.agent_stub.cli._files import download_file_from_environment, upl
from dify_agent.agent_stub.client._errors import AgentStubClientError
from dify_agent.agent_stub.protocol.agent_stub import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
_CONFIG_MANIFEST_STDOUT_EXCLUDE = {
"skills": {"items": {"__all__": {"hash"}}},
"files": {"items": {"__all__": {"hash"}}},
}
app = typer.Typer(
add_completion=False,
help="Forward shell-visible dify-agent commands to the Dify Agent Stub server.",
no_args_is_help=True,
rich_markup_mode=None,
)
file_app = typer.Typer(help="Upload or download workflow files through the Agent Stub.")
drive_app = typer.Typer(help="List, pull, or push agent drive files through the Agent Stub.")
file_app = typer.Typer(help="Upload or download workflow files through the Agent Stub.", rich_markup_mode=None)
config_app = typer.Typer(
help="Inspect or update Agent Soul-backed config assets through the Agent Stub.",
rich_markup_mode=None,
)
config_skills_app = typer.Typer(help="Pull or update config skills through the Agent Stub.", rich_markup_mode=None)
config_files_app = typer.Typer(help="Pull or update config files through the Agent Stub.", rich_markup_mode=None)
config_skill_pull_alias_app = typer.Typer(help="Pull config skills through the Agent Stub.", rich_markup_mode=None)
config_file_pull_alias_app = typer.Typer(help="Pull config files through the Agent Stub.", rich_markup_mode=None)
config_env_app = typer.Typer(
help="Pull or update config env variables visible to the current run.", rich_markup_mode=None
)
config_note_app = typer.Typer(help="Pull or update the current config note.", rich_markup_mode=None)
drive_app = typer.Typer(help="List, pull, or push agent drive files through the Agent Stub.", rich_markup_mode=None)
app.add_typer(file_app, name="file")
app.add_typer(config_app, name="config")
config_app.add_typer(config_skills_app, name="skills")
# Keep hidden singular aliases on separate pull-only apps. Reusing the plural
# apps here would also expose push/delete through `config skill|file ...`,
# which is intentionally not part of the compatibility surface.
config_app.add_typer(config_skill_pull_alias_app, name="skill", hidden=True)
config_app.add_typer(config_files_app, name="files")
config_app.add_typer(config_file_pull_alias_app, name="file", hidden=True)
config_app.add_typer(config_env_app, name="env")
config_app.add_typer(config_note_app, name="note")
app.add_typer(drive_app, name="drive")
_KNOWN_ROOT_COMMANDS = frozenset({"connect", "drive", "file"})
_KNOWN_ROOT_COMMANDS = frozenset({"config", "connect", "drive", "file"})
@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
@@ -77,6 +119,121 @@ def download(
)
@config_app.command("manifest")
def config_manifest() -> None:
"""Show the current visible Agent config manifest as JSON."""
_run_config_manifest()
@config_skills_app.command("pull")
def config_skills_pull(
names: list[str] = typer.Argument(None, metavar="NAME"),
local_dir: str | None = typer.Option(None, "--to", help="Local directory for pulled config skills."),
json_output: bool = typer.Option(False, "--json", help="Emit the pull result as JSON."),
) -> None:
"""Pull one or all visible config skills into ./.dify_conf/skills by default."""
_run_config_skill_pull(names=names or None, local_dir=local_dir, json_output=json_output)
@config_skill_pull_alias_app.command("pull")
def config_skill_pull_alias(
names: list[str] = typer.Argument(None, metavar="NAME"),
local_dir: str | None = typer.Option(None, "--to", help="Local directory for pulled config skills."),
json_output: bool = typer.Option(False, "--json", help="Emit the pull result as JSON."),
) -> None:
"""Pull one or all visible config skills into ./.dify_conf/skills by default."""
_run_config_skill_pull(names=names or None, local_dir=local_dir, json_output=json_output)
@config_skills_app.command("push")
def config_skills_push(
paths: list[str] = typer.Argument(..., metavar="PATH"),
) -> None:
"""Upload one or more local skill directories into the current config manifest."""
_run_config_skills_push(paths=paths)
@config_skills_app.command("delete")
def config_skills_delete(
names: list[str] = typer.Argument(..., metavar="NAME"),
) -> None:
"""Delete one or more config skills by name without touching local directories."""
_run_config_skills_delete(names=names)
@config_files_app.command("pull")
def config_files_pull(
names: list[str] = typer.Argument(None, metavar="NAME"),
local_dir: str | None = typer.Option(None, "--to", help="Local directory for pulled config files."),
json_output: bool = typer.Option(False, "--json", help="Emit the pull result as JSON."),
) -> None:
"""Pull one or all visible config files into ./.dify_conf/files by default."""
_run_config_file_pull(names=names or None, local_dir=local_dir, json_output=json_output)
@config_file_pull_alias_app.command("pull")
def config_file_pull_alias(
names: list[str] = typer.Argument(None, metavar="NAME"),
local_dir: str | None = typer.Option(None, "--to", help="Local directory for pulled config files."),
json_output: bool = typer.Option(False, "--json", help="Emit the pull result as JSON."),
) -> None:
"""Pull one or all visible config files into ./.dify_conf/files by default."""
_run_config_file_pull(names=names or None, local_dir=local_dir, json_output=json_output)
@config_files_app.command("push")
def config_files_push(
paths: list[str] = typer.Argument(..., metavar="PATH"),
name: str | None = typer.Option(
None,
"--name",
help="Override the target config file name when uploading exactly one local file.",
),
) -> None:
"""Upload one or more local files into the current config manifest."""
_run_config_files_push(paths=paths, name=name)
@config_files_app.command("delete")
def config_files_delete(
names: list[str] = typer.Argument(..., metavar="NAME"),
) -> None:
"""Delete one or more config files by name without touching local files."""
_run_config_files_delete(names=names)
@config_env_app.command("pull")
def config_env_pull(
local_path: str | None = typer.Option(None, "--to", help="Local dotenv file path."),
) -> None:
"""Export visible config env values into ./.dify_conf/.env by default."""
_run_config_env_pull(local_path=local_path)
@config_env_app.command("push")
def config_env_push(
local_path: str | None = typer.Argument(None, metavar="PATH|-"),
) -> None:
"""Replace visible config env values from one local dotenv file or stdin."""
_run_config_env_push(local_path=local_path)
@config_note_app.command("pull")
def config_note_pull(
local_path: str | None = typer.Option(None, "--to", help="Local markdown file path."),
) -> None:
"""Export the current config note into ./.dify_conf/note.md by default."""
_run_config_note_pull(local_path=local_path)
@config_note_app.command("push")
def config_note_push(
local_path: str | None = typer.Argument(None, metavar="PATH|-"),
) -> None:
"""Replace the current config note from one local text file or stdin."""
_run_config_note_push(local_path=local_path)
@drive_app.command("list")
def drive_list(
path_prefix: str = typer.Argument("", metavar="REMOTE_PREFIX"),
@@ -174,6 +331,25 @@ def _show_root_help() -> None:
typer.echo(command.get_help(context))
def render_agent_stub_cli_help(args: tuple[str, ...]) -> str:
"""Render Click help for one known ``dify-agent`` subcommand without executing a shell."""
command: click.Command = get_command(app)
parent_context: click.Context | None = None
command_path = ["dify-agent"]
for name in args:
if not isinstance(command, click.Group):
raise ValueError(f"dify-agent {' '.join(args)} is not a command group")
next_command = command.commands.get(name)
if next_command is None:
raise ValueError(f"unknown dify-agent command path: {' '.join(args)}")
current_context = click.Context(command, info_name=command_path[-1], parent=parent_context)
parent_context = current_context
command = next_command
command_path.append(name)
context = click.Context(command, info_name=command_path[-1], parent=parent_context)
return command.get_help(context).strip()
def _run_connect(*, argv: list[str], json_output: bool) -> None:
try:
response = connect_from_environment(argv=argv)
@@ -225,6 +401,149 @@ def _run_file_download(
typer.echo(str(response.path))
def _run_config_manifest() -> None:
try:
response = manifest_from_environment()
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
typer.echo(response.model_dump_json(exclude=_CONFIG_MANIFEST_STDOUT_EXCLUDE))
def _run_config_skill_pull(*, names: list[str] | None, local_dir: str | None, json_output: bool) -> None:
try:
response = pull_config_skills_from_environment(names=names, local_dir=local_dir)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
if json_output:
typer.echo(response.model_dump_json())
return
for index, item in enumerate(response.items):
if index:
typer.echo("")
typer.echo(item.directory_path)
typer.echo(item.skill_md, nl=False)
def _run_config_file_pull(*, names: list[str] | None, local_dir: str | None, json_output: bool) -> None:
try:
response = pull_config_files_from_environment(names=names, local_dir=local_dir)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
if json_output:
typer.echo(response.model_dump_json())
return
for item in response.items:
typer.echo(item.path)
def _run_config_env_pull(*, local_path: str | None) -> None:
try:
path = pull_config_env_from_environment(local_path=local_path)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
typer.echo(str(path))
def _run_config_note_pull(*, local_path: str | None) -> None:
try:
path = pull_config_note_from_environment(local_path=local_path)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
typer.echo(str(path))
def _run_config_note_push(*, local_path: str | None) -> None:
try:
response = push_config_note_from_environment(local_path=local_path)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
typer.echo(response.model_dump_json())
def _run_config_env_push(*, local_path: str | None) -> None:
try:
response = push_config_env_from_environment(local_path=local_path)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
typer.echo(response.model_dump_json())
def _run_config_files_push(*, paths: list[str], name: str | None) -> None:
try:
response = push_config_files_from_environment(paths=paths, name=name)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
typer.echo(response.model_dump_json())
def _run_config_files_delete(*, names: list[str]) -> None:
try:
response = delete_config_files_from_environment(names=names)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
typer.echo(response.model_dump_json())
def _run_config_skills_push(*, paths: list[str]) -> None:
try:
response = push_config_skills_from_environment(paths=paths)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
typer.echo(response.model_dump_json())
def _run_config_skills_delete(*, names: list[str]) -> None:
try:
response = delete_config_skills_from_environment(names=names)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
typer.echo(response.model_dump_json())
def _run_drive_list(*, path_prefix: str, json_output: bool) -> None:
try:
response = list_drive_manifest_from_environment(prefix=path_prefix)
@@ -8,6 +8,13 @@ from pydantic import JsonValue
from dify_agent.agent_stub.client._agent_stub_http import (
connect_agent_stub_http_sync,
download_file_bytes_from_signed_url_sync,
request_agent_stub_config_env_update_http_sync,
request_agent_stub_config_file_pull_http_sync,
request_agent_stub_config_manifest_http_sync,
request_agent_stub_config_note_update_http_sync,
request_agent_stub_config_push_http_sync,
request_agent_stub_config_skill_inspect_http_sync,
request_agent_stub_config_skill_pull_http_sync,
request_agent_stub_drive_commit_http_sync,
request_agent_stub_drive_manifest_http_sync,
request_agent_stub_file_download_http_sync,
@@ -16,9 +23,10 @@ from dify_agent.agent_stub.client._agent_stub_http import (
)
from dify_agent.agent_stub.client._errors import AgentStubValidationError
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConfigPushRequest,
AgentStubDriveCommitRequest,
AgentStubFileMapping,
parse_agent_stub_endpoint,
AgentStubFileMapping,
)
@@ -166,7 +174,157 @@ def request_agent_stub_drive_commit_sync(
)
def request_agent_stub_config_manifest_sync(
*,
url: str,
auth_jwe: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
):
"""Fetch the current config manifest through the HTTP Agent Stub transport.
Config operations are HTTP-only in this stage. ``grpc://`` endpoints raise
``AgentStubValidationError`` instead of attempting transport fallback.
"""
endpoint = _parse_endpoint(url)
if endpoint.is_grpc:
raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL")
return request_agent_stub_config_manifest_http_sync(
base_url=endpoint.url,
auth_jwe=auth_jwe,
timeout=timeout,
sync_http_client=sync_http_client,
)
def request_agent_stub_config_skill_pull_sync(
*,
url: str,
auth_jwe: str,
name: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
):
"""Download one config skill archive through the HTTP Agent Stub transport."""
endpoint = _parse_endpoint(url)
if endpoint.is_grpc:
raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL")
return request_agent_stub_config_skill_pull_http_sync(
base_url=endpoint.url,
auth_jwe=auth_jwe,
name=name,
timeout=timeout,
sync_http_client=sync_http_client,
)
def request_agent_stub_config_skill_inspect_sync(
*,
url: str,
auth_jwe: str,
name: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
):
"""Fetch one config skill inspect view through the HTTP Agent Stub transport."""
endpoint = _parse_endpoint(url)
if endpoint.is_grpc:
raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL")
return request_agent_stub_config_skill_inspect_http_sync(
base_url=endpoint.url,
auth_jwe=auth_jwe,
name=name,
timeout=timeout,
sync_http_client=sync_http_client,
)
def request_agent_stub_config_file_pull_sync(
*,
url: str,
auth_jwe: str,
name: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
):
"""Download one config file payload through the HTTP Agent Stub transport."""
endpoint = _parse_endpoint(url)
if endpoint.is_grpc:
raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL")
return request_agent_stub_config_file_pull_http_sync(
base_url=endpoint.url,
auth_jwe=auth_jwe,
name=name,
timeout=timeout,
sync_http_client=sync_http_client,
)
def request_agent_stub_config_push_sync(
*,
url: str,
auth_jwe: str,
request: AgentStubConfigPushRequest,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
):
"""Push config file/skill/env/note mutations through the HTTP transport."""
endpoint = _parse_endpoint(url)
if endpoint.is_grpc:
raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL")
return request_agent_stub_config_push_http_sync(
base_url=endpoint.url,
auth_jwe=auth_jwe,
request=request,
timeout=timeout,
sync_http_client=sync_http_client,
)
def request_agent_stub_config_env_update_sync(
*,
url: str,
auth_jwe: str,
env_text: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
):
"""Replace or delete config env entries through the HTTP transport."""
endpoint = _parse_endpoint(url)
if endpoint.is_grpc:
raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL")
return request_agent_stub_config_env_update_http_sync(
base_url=endpoint.url,
auth_jwe=auth_jwe,
env_text=env_text,
timeout=timeout,
sync_http_client=sync_http_client,
)
def request_agent_stub_config_note_update_sync(
*,
url: str,
auth_jwe: str,
note: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
):
"""Update the config note text through the HTTP Agent Stub transport."""
endpoint = _parse_endpoint(url)
if endpoint.is_grpc:
raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL")
return request_agent_stub_config_note_update_http_sync(
base_url=endpoint.url,
auth_jwe=auth_jwe,
note=note,
timeout=timeout,
sync_http_client=sync_http_client,
)
def _parse_endpoint(url: str):
"""Normalize one Agent Stub base URL and map parse failures to client errors."""
try:
return parse_agent_stub_endpoint(url)
except ValueError as exc:
@@ -176,6 +334,13 @@ def _parse_endpoint(url: str):
__all__ = [
"connect_agent_stub_sync",
"download_file_bytes_from_signed_url_sync",
"request_agent_stub_config_env_update_sync",
"request_agent_stub_config_file_pull_sync",
"request_agent_stub_config_manifest_sync",
"request_agent_stub_config_note_update_sync",
"request_agent_stub_config_push_sync",
"request_agent_stub_config_skill_inspect_sync",
"request_agent_stub_config_skill_pull_sync",
"request_agent_stub_drive_commit_sync",
"request_agent_stub_drive_manifest_sync",
"request_agent_stub_file_download_sync",
@@ -8,6 +8,7 @@ must stay safe to import in default installations, so these helpers live under
from __future__ import annotations
import json
from collections.abc import Callable
from typing import BinaryIO
from typing import cast
@@ -24,6 +25,11 @@ from dify_agent.agent_stub.client._errors import (
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConnectRequest,
AgentStubConnectResponse,
AgentStubConfigEnvUpdateRequest,
AgentStubConfigManifestResponse,
AgentStubConfigNoteUpdateRequest,
AgentStubConfigPushRequest,
AgentStubConfigPushResponse,
AgentStubDriveCommitRequest,
AgentStubDriveCommitResponse,
AgentStubDriveManifestResponse,
@@ -32,6 +38,13 @@ from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubFileMapping,
AgentStubFileUploadRequest,
AgentStubFileUploadResponse,
agent_stub_config_env_url,
agent_stub_config_file_pull_url,
agent_stub_config_manifest_url,
agent_stub_config_note_url,
agent_stub_config_push_url,
agent_stub_config_skill_inspect_url,
agent_stub_config_skill_pull_url,
agent_stub_connections_url,
agent_stub_drive_commit_url,
agent_stub_drive_manifest_url,
@@ -172,13 +185,213 @@ def request_agent_stub_drive_commit_http_sync(
auth_jwe=auth_jwe,
endpoint_name="drive commit request",
endpoint_url_factory=agent_stub_drive_commit_url,
request_body=request.model_dump_json(exclude_none=True),
request_body=_dump_drive_commit_request(request),
timeout=timeout,
sync_http_client=sync_http_client,
)
return _parse_success_response(response=response, response_model=AgentStubDriveCommitResponse, label="drive commit")
def request_agent_stub_config_manifest_http_sync(
*,
base_url: str,
auth_jwe: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
) -> AgentStubConfigManifestResponse:
"""Fetch the current config manifest from the HTTP Agent Stub endpoint."""
response = _get_agent_stub_json(
base_url=base_url,
auth_jwe=auth_jwe,
endpoint_name="config manifest request",
endpoint_url_factory=agent_stub_config_manifest_url,
params={},
timeout=timeout,
sync_http_client=sync_http_client,
)
return _parse_success_response(
response=response,
response_model=AgentStubConfigManifestResponse,
label="config manifest",
)
def request_agent_stub_config_skill_pull_http_sync(
*,
base_url: str,
auth_jwe: str,
name: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
) -> bytes:
"""Download one config skill archive from the HTTP Agent Stub endpoint."""
response = _get_agent_stub_json(
base_url=base_url,
auth_jwe=auth_jwe,
endpoint_name="config skill pull request",
endpoint_url_factory=lambda resolved_base_url: agent_stub_config_skill_pull_url(resolved_base_url, name),
params={},
timeout=timeout,
sync_http_client=sync_http_client,
)
if response.is_error:
payload = _parse_json_payload(
response, invalid_json_message="Agent Stub returned invalid JSON for config skill pull"
)
detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
raise AgentStubHTTPError(response.status_code, detail)
return response.content
def request_agent_stub_config_skill_inspect_http_sync(
*,
base_url: str,
auth_jwe: str,
name: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
) -> dict[str, object]:
"""Fetch the JSON inspect view for one config skill."""
response = _get_agent_stub_json(
base_url=base_url,
auth_jwe=auth_jwe,
endpoint_name="config skill inspect request",
endpoint_url_factory=lambda resolved_base_url: agent_stub_config_skill_inspect_url(resolved_base_url, name),
params={},
timeout=timeout,
sync_http_client=sync_http_client,
)
payload = _parse_json_payload(
response, invalid_json_message="Agent Stub returned invalid JSON for config skill inspect"
)
if response.is_error:
detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
raise AgentStubHTTPError(response.status_code, detail)
if not isinstance(payload, dict):
raise AgentStubValidationError("invalid Agent Stub config skill inspect response")
return cast(dict[str, object], payload)
def request_agent_stub_config_file_pull_http_sync(
*,
base_url: str,
auth_jwe: str,
name: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
) -> bytes:
"""Download one config file payload from the HTTP Agent Stub endpoint."""
response = _get_agent_stub_json(
base_url=base_url,
auth_jwe=auth_jwe,
endpoint_name="config file pull request",
endpoint_url_factory=lambda resolved_base_url: agent_stub_config_file_pull_url(resolved_base_url, name),
params={},
timeout=timeout,
sync_http_client=sync_http_client,
)
if response.is_error:
payload = _parse_json_payload(
response, invalid_json_message="Agent Stub returned invalid JSON for config file pull"
)
detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
raise AgentStubHTTPError(response.status_code, detail)
return response.content
def request_agent_stub_config_push_http_sync(
*,
base_url: str,
auth_jwe: str,
request: AgentStubConfigPushRequest,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
) -> AgentStubConfigPushResponse:
"""Push config file/skill/env/note mutations to the HTTP Agent Stub endpoint."""
response = _post_agent_stub_json(
base_url=base_url,
auth_jwe=auth_jwe,
endpoint_name="config push request",
endpoint_url_factory=agent_stub_config_push_url,
request_body=request.model_dump_json(exclude_none=True, exclude_defaults=True),
timeout=timeout,
sync_http_client=sync_http_client,
)
return _parse_success_response(response=response, response_model=AgentStubConfigPushResponse, label="config push")
def _dump_drive_commit_request(request: AgentStubDriveCommitRequest) -> str:
payload = cast(dict[str, object], request.model_dump(mode="json", exclude_none=True))
items = payload.get("items")
if isinstance(items, list):
for item in items:
if isinstance(item, dict) and item.get("is_skill") is False:
item.pop("is_skill")
return json.dumps(payload, separators=(",", ":"))
def request_agent_stub_config_env_update_http_sync(
*,
base_url: str,
auth_jwe: str,
env_text: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
) -> dict[str, object]:
"""Replace or delete config env entries through the HTTP Agent Stub endpoint."""
request_model = AgentStubConfigEnvUpdateRequest(env_text=env_text)
response = _send_agent_stub_json(
method="PATCH",
base_url=base_url,
auth_jwe=auth_jwe,
endpoint_name="config env update request",
endpoint_url_factory=agent_stub_config_env_url,
request_body=request_model.model_dump_json(),
timeout=timeout,
sync_http_client=sync_http_client,
)
payload = _parse_json_payload(
response, invalid_json_message="Agent Stub returned invalid JSON for config env update"
)
if response.is_error:
detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
raise AgentStubHTTPError(response.status_code, detail)
if not isinstance(payload, dict):
raise AgentStubValidationError("invalid Agent Stub config env update response")
return cast(dict[str, object], payload)
def request_agent_stub_config_note_update_http_sync(
*,
base_url: str,
auth_jwe: str,
note: str,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
) -> dict[str, object]:
"""Update the config note text through the HTTP Agent Stub endpoint."""
request_model = AgentStubConfigNoteUpdateRequest(note=note)
response = _send_agent_stub_json(
method="PUT",
base_url=base_url,
auth_jwe=auth_jwe,
endpoint_name="config note update request",
endpoint_url_factory=agent_stub_config_note_url,
request_body=request_model.model_dump_json(),
timeout=timeout,
sync_http_client=sync_http_client,
)
payload = _parse_json_payload(
response, invalid_json_message="Agent Stub returned invalid JSON for config note update"
)
if response.is_error:
detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
raise AgentStubHTTPError(response.status_code, detail)
if not isinstance(payload, dict):
raise AgentStubValidationError("invalid Agent Stub config note update response")
return cast(dict[str, object], payload)
def upload_file_to_signed_url_sync(
*,
upload_url: str,
@@ -258,6 +471,29 @@ def _post_agent_stub_json(
request_body: str,
timeout: float | httpx.Timeout,
sync_http_client: httpx.Client | None,
) -> httpx.Response:
return _send_agent_stub_json(
method="POST",
base_url=base_url,
auth_jwe=auth_jwe,
endpoint_name=endpoint_name,
endpoint_url_factory=endpoint_url_factory,
request_body=request_body,
timeout=timeout,
sync_http_client=sync_http_client,
)
def _send_agent_stub_json(
*,
method: str,
base_url: str,
auth_jwe: str,
endpoint_name: str,
endpoint_url_factory: Callable[[str], str],
request_body: str,
timeout: float | httpx.Timeout,
sync_http_client: httpx.Client | None,
) -> httpx.Response:
try:
endpoint_url = endpoint_url_factory(base_url)
@@ -266,7 +502,8 @@ def _post_agent_stub_json(
owns_client = sync_http_client is None
client = sync_http_client or httpx.Client(timeout=timeout, follow_redirects=True)
try:
return client.post(
return client.request(
method,
endpoint_url,
content=request_body,
headers={
@@ -344,6 +581,13 @@ def _parse_json_payload(response: httpx.Response, *, invalid_json_message: str)
__all__ = [
"connect_agent_stub_http_sync",
"download_file_bytes_from_signed_url_sync",
"request_agent_stub_config_env_update_http_sync",
"request_agent_stub_config_file_pull_http_sync",
"request_agent_stub_config_manifest_http_sync",
"request_agent_stub_config_note_update_http_sync",
"request_agent_stub_config_push_http_sync",
"request_agent_stub_config_skill_inspect_http_sync",
"request_agent_stub_config_skill_pull_http_sync",
"request_agent_stub_drive_commit_http_sync",
"request_agent_stub_drive_manifest_http_sync",
"request_agent_stub_file_download_http_sync",
@@ -8,6 +8,17 @@ from .agent_stub import (
DEFAULT_AGENT_STUB_DRIVE_BASE,
AgentStubConnectRequest,
AgentStubConnectResponse,
AgentStubConfigEnvUpdateRequest,
AgentStubConfigFileItem,
AgentStubConfigFileRef,
AgentStubConfigManifestResponse,
AgentStubConfigNoteUpdateRequest,
AgentStubConfigPushFileItem,
AgentStubConfigPushRequest,
AgentStubConfigPushResponse,
AgentStubConfigPushSkillItem,
AgentStubConfigSkillItem,
AgentStubConfigVersionInfo,
AgentStubDriveCommitItem,
AgentStubDriveCommitRequest,
AgentStubDriveCommitResponse,
@@ -21,6 +32,13 @@ from .agent_stub import (
AgentStubFileUploadRequest,
AgentStubFileUploadResponse,
AgentStubURLScheme,
agent_stub_config_env_url,
agent_stub_config_file_pull_url,
agent_stub_config_manifest_url,
agent_stub_config_note_url,
agent_stub_config_push_url,
agent_stub_config_skill_inspect_url,
agent_stub_config_skill_pull_url,
agent_stub_connections_url,
agent_stub_drive_base_for_ref,
agent_stub_drive_commit_url,
@@ -40,6 +58,17 @@ __all__ = [
"DEFAULT_AGENT_STUB_DRIVE_BASE",
"AgentStubConnectRequest",
"AgentStubConnectResponse",
"AgentStubConfigEnvUpdateRequest",
"AgentStubConfigFileItem",
"AgentStubConfigFileRef",
"AgentStubConfigManifestResponse",
"AgentStubConfigNoteUpdateRequest",
"AgentStubConfigPushFileItem",
"AgentStubConfigPushRequest",
"AgentStubConfigPushResponse",
"AgentStubConfigPushSkillItem",
"AgentStubConfigSkillItem",
"AgentStubConfigVersionInfo",
"AgentStubDriveCommitItem",
"AgentStubDriveCommitRequest",
"AgentStubDriveCommitResponse",
@@ -53,6 +82,13 @@ __all__ = [
"AgentStubFileUploadRequest",
"AgentStubFileUploadResponse",
"AgentStubURLScheme",
"agent_stub_config_env_url",
"agent_stub_config_file_pull_url",
"agent_stub_config_manifest_url",
"agent_stub_config_note_url",
"agent_stub_config_push_url",
"agent_stub_config_skill_inspect_url",
"agent_stub_config_skill_pull_url",
"agent_stub_connections_url",
"agent_stub_drive_base_for_ref",
"agent_stub_drive_commit_url",
@@ -142,6 +142,35 @@ def agent_stub_drive_commit_url(base_url: str) -> str:
return f"{_require_http_base_url(base_url)}/drive/commit"
def agent_stub_config_manifest_url(base_url: str) -> str:
"""Return the stable HTTP config-manifest endpoint URL for one base URL."""
return f"{_require_http_base_url(base_url)}/config/manifest"
def agent_stub_config_skill_pull_url(base_url: str, name: str) -> str:
return f"{_require_http_base_url(base_url)}/config/skills/{name}/pull"
def agent_stub_config_skill_inspect_url(base_url: str, name: str) -> str:
return f"{_require_http_base_url(base_url)}/config/skills/{name}/inspect"
def agent_stub_config_file_pull_url(base_url: str, name: str) -> str:
return f"{_require_http_base_url(base_url)}/config/files/{name}/pull"
def agent_stub_config_push_url(base_url: str) -> str:
return f"{_require_http_base_url(base_url)}/config/push"
def agent_stub_config_env_url(base_url: str) -> str:
return f"{_require_http_base_url(base_url)}/config/env"
def agent_stub_config_note_url(base_url: str) -> str:
return f"{_require_http_base_url(base_url)}/config/note"
def is_canonical_dify_file_reference(reference: str) -> bool:
"""Return whether one string matches Dify's opaque file reference format."""
prefix = "dify-file-ref:"
@@ -189,8 +218,6 @@ class AgentStubFileUploadResponse(BaseModel):
upload_url: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubFileMapping(BaseModel):
"""Public file mapping used by download-request control-plane calls."""
@@ -234,8 +261,6 @@ class AgentStubFileDownloadResponse(BaseModel):
size: int
download_url: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubDriveFileRef(BaseModel):
"""Trusted file reference used by Agent Stub drive commit requests."""
@@ -294,14 +319,94 @@ class AgentStubDriveManifestResponse(BaseModel):
items: list[AgentStubDriveItem]
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubDriveCommitResponse(BaseModel):
"""Response body for one Agent Stub drive commit request."""
items: list[AgentStubDriveItem]
class AgentStubConfigVersionInfo(BaseModel):
id: str
kind: Literal["snapshot", "draft", "build_draft"]
writable: bool
class AgentStubConfigSkillItem(BaseModel):
name: str
description: str
size: int | None = None
hash: str | None = None
mime_type: str | None = None
class AgentStubConfigSkillItemsResponse(BaseModel):
items: list[AgentStubConfigSkillItem] = Field(default_factory=list)
class AgentStubConfigFileItem(BaseModel):
name: str
size: int | None = None
hash: str | None = None
mime_type: str | None = None
class AgentStubConfigFileItemsResponse(BaseModel):
items: list[AgentStubConfigFileItem] = Field(default_factory=list)
class AgentStubConfigManifestResponse(BaseModel):
agent_id: str
config_version: AgentStubConfigVersionInfo
skills: AgentStubConfigSkillItemsResponse = Field(default_factory=AgentStubConfigSkillItemsResponse)
files: AgentStubConfigFileItemsResponse = Field(default_factory=AgentStubConfigFileItemsResponse)
env_keys: list[str] = Field(default_factory=list)
note: str = ""
class AgentStubConfigFileRef(BaseModel):
kind: Literal["upload_file", "tool_file"]
id: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubConfigPushFileItem(BaseModel):
name: str
file_ref: AgentStubConfigFileRef | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubConfigPushSkillItem(BaseModel):
name: str
file_ref: AgentStubConfigFileRef | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubConfigPushRequest(BaseModel):
files: list[AgentStubConfigPushFileItem] = Field(default_factory=list)
skills: list[AgentStubConfigPushSkillItem] = Field(default_factory=list)
env_text: str | None = None
note: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubConfigPushResponse(AgentStubConfigManifestResponse):
"""Updated config manifest returned after one config push."""
class AgentStubConfigEnvUpdateRequest(BaseModel):
env_text: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubConfigNoteUpdateRequest(BaseModel):
note: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@@ -325,6 +430,19 @@ __all__ = [
"AgentStubConnectRequest",
"AgentStubConnectResponse",
"AgentStubEndpoint",
"AgentStubConfigEnvUpdateRequest",
"AgentStubConfigFileItem",
"AgentStubConfigFileItemsResponse",
"AgentStubConfigFileRef",
"AgentStubConfigManifestResponse",
"AgentStubConfigNoteUpdateRequest",
"AgentStubConfigPushFileItem",
"AgentStubConfigPushRequest",
"AgentStubConfigPushResponse",
"AgentStubConfigPushSkillItem",
"AgentStubConfigSkillItem",
"AgentStubConfigSkillItemsResponse",
"AgentStubConfigVersionInfo",
"AgentStubDriveCommitItem",
"AgentStubDriveCommitRequest",
"AgentStubDriveCommitResponse",
@@ -337,6 +455,13 @@ __all__ = [
"AgentStubFileUploadRequest",
"AgentStubFileUploadResponse",
"AgentStubURLScheme",
"agent_stub_config_env_url",
"agent_stub_config_file_pull_url",
"agent_stub_config_manifest_url",
"agent_stub_config_note_url",
"agent_stub_config_push_url",
"agent_stub_config_skill_inspect_url",
"agent_stub_config_skill_pull_url",
"agent_stub_connections_url",
"agent_stub_drive_base_for_ref",
"agent_stub_drive_commit_url",
@@ -0,0 +1,262 @@
"""Server-side Dify API client for Agent Stub config endpoints.
Config requests are scoped entirely by the signed execution context carried in
the Agent Stub token. Tenant, agent, user, and config-version identifiers come
only from that trusted context; sandbox request bodies contribute only mutable
content such as asset names, env text, and note text.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Protocol
import httpx
from pydantic import ValidationError
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConfigManifestResponse,
AgentStubConfigPushRequest,
AgentStubConfigPushResponse,
)
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
class AgentStubConfigRequestHandler(Protocol):
async def manifest(self, *, principal: AgentStubPrincipal) -> AgentStubConfigManifestResponse: ...
async def pull_skill(self, *, principal: AgentStubPrincipal, name: str) -> bytes: ...
async def inspect_skill(self, *, principal: AgentStubPrincipal, name: str) -> dict[str, object]: ...
async def pull_file(self, *, principal: AgentStubPrincipal, name: str) -> bytes: ...
async def push(
self,
*,
principal: AgentStubPrincipal,
request: AgentStubConfigPushRequest,
) -> AgentStubConfigPushResponse: ...
async def update_env(self, *, principal: AgentStubPrincipal, env_text: str) -> dict[str, object]: ...
async def update_note(self, *, principal: AgentStubPrincipal, note: str) -> dict[str, object]: ...
class AgentStubConfigRequestError(RuntimeError):
status_code: int
detail: object
def __init__(self, status_code: int, detail: object) -> None:
self.status_code = status_code
self.detail = detail
super().__init__(str(detail))
@dataclass(slots=True)
class DifyApiAgentStubConfigRequestHandler:
"""Call Dify API inner config endpoints on behalf of authenticated sandboxes.
The sandbox never chooses tenant, agent, user, or config-version scope
directly. Those routing fields are derived from the signed execution
context, while request payloads only carry mutable config content.
"""
inner_api_url: str
inner_api_key: str
timeout: httpx.Timeout | float = 30.0
async def manifest(self, *, principal: AgentStubPrincipal) -> AgentStubConfigManifestResponse:
execution_context = self._require_config_context(principal.execution_context)
payload = await self._get_inner_api_json(
f"/inner/api/agent-config/{execution_context.agent_id}/manifest",
self._config_query_params(execution_context),
)
try:
return AgentStubConfigManifestResponse.model_validate(payload)
except ValidationError as exc:
raise AgentStubConfigRequestError(502, "Dify API config manifest response is invalid") from exc
async def pull_skill(self, *, principal: AgentStubPrincipal, name: str) -> bytes:
execution_context = self._require_config_context(principal.execution_context)
return await self._get_inner_api_bytes(
f"/inner/api/agent-config/{execution_context.agent_id}/skills/{name}/pull",
self._config_query_params(execution_context),
)
async def inspect_skill(self, *, principal: AgentStubPrincipal, name: str) -> dict[str, object]:
execution_context = self._require_config_context(principal.execution_context)
payload = await self._get_inner_api_json(
f"/inner/api/agent-config/{execution_context.agent_id}/skills/{name}/inspect",
self._config_query_params(execution_context),
)
if not isinstance(payload, dict):
raise AgentStubConfigRequestError(502, "Dify API config skill inspect response is invalid")
return payload
async def pull_file(self, *, principal: AgentStubPrincipal, name: str) -> bytes:
execution_context = self._require_config_context(principal.execution_context)
return await self._get_inner_api_bytes(
f"/inner/api/agent-config/{execution_context.agent_id}/files/{name}/pull",
self._config_query_params(execution_context),
)
async def push(
self,
*,
principal: AgentStubPrincipal,
request: AgentStubConfigPushRequest,
) -> AgentStubConfigPushResponse:
execution_context = self._require_user_context(self._require_config_context(principal.execution_context))
payload = await self._post_inner_api_json(
f"/inner/api/agent-config/{execution_context.agent_id}/push",
{
"tenant_id": execution_context.tenant_id,
"user_id": execution_context.user_id,
"config_version_id": execution_context.agent_config_version_id,
"config_version_kind": execution_context.agent_config_version_kind,
**request.model_dump(mode="json", exclude_none=True),
},
)
try:
return AgentStubConfigPushResponse.model_validate(payload)
except ValidationError as exc:
raise AgentStubConfigRequestError(502, "Dify API config push response is invalid") from exc
async def update_env(self, *, principal: AgentStubPrincipal, env_text: str) -> dict[str, object]:
execution_context = self._require_user_context(self._require_config_context(principal.execution_context))
payload = await self._patch_inner_api_json(
f"/inner/api/agent-config/{execution_context.agent_id}/env",
{
"tenant_id": execution_context.tenant_id,
"user_id": execution_context.user_id,
"config_version_id": execution_context.agent_config_version_id,
"config_version_kind": execution_context.agent_config_version_kind,
"env_text": env_text,
},
)
if not isinstance(payload, dict):
raise AgentStubConfigRequestError(502, "Dify API config env response is invalid")
return payload
async def update_note(self, *, principal: AgentStubPrincipal, note: str) -> dict[str, object]:
execution_context = self._require_user_context(self._require_config_context(principal.execution_context))
payload = await self._put_inner_api_json(
f"/inner/api/agent-config/{execution_context.agent_id}/note",
{
"tenant_id": execution_context.tenant_id,
"user_id": execution_context.user_id,
"config_version_id": execution_context.agent_config_version_id,
"config_version_kind": execution_context.agent_config_version_kind,
"note": note,
},
)
if not isinstance(payload, dict):
raise AgentStubConfigRequestError(502, "Dify API config note response is invalid")
return payload
@staticmethod
def _require_config_context(execution_context: DifyExecutionContextLayerConfig) -> DifyExecutionContextLayerConfig:
if execution_context.agent_id is None:
raise AgentStubConfigRequestError(400, "execution context agent_id is required for config operations")
if execution_context.agent_config_version_id is None:
raise AgentStubConfigRequestError(400, "execution context agent_config_version_id is required")
if execution_context.agent_config_version_kind is None:
raise AgentStubConfigRequestError(400, "execution context agent_config_version_kind is required")
return execution_context
@staticmethod
def _require_user_context(execution_context: DifyExecutionContextLayerConfig) -> DifyExecutionContextLayerConfig:
if execution_context.user_id is None:
raise AgentStubConfigRequestError(400, "execution context user_id is required for config write operations")
return execution_context
@staticmethod
def _config_query_params(execution_context: DifyExecutionContextLayerConfig) -> dict[str, str]:
params = {
"tenant_id": execution_context.tenant_id,
"config_version_id": execution_context.agent_config_version_id or "",
"config_version_kind": execution_context.agent_config_version_kind or "",
}
if execution_context.user_id is not None:
params["user_id"] = execution_context.user_id
return params
async def _get_inner_api_json(self, path: str, params: Mapping[str, str]) -> object:
response = await self._request("GET", path, params=dict(params))
return self._normalize_json_payload(
response, invalid_json_detail="Dify API config request returned invalid JSON"
)
async def _get_inner_api_bytes(self, path: str, params: Mapping[str, str]) -> bytes:
response = await self._request("GET", path, params=dict(params))
if response.is_error:
detail = self._normalize_json_payload(
response,
invalid_json_detail="Dify API config request returned invalid JSON",
)
raise AgentStubConfigRequestError(
response.status_code, detail.get("detail", detail) if isinstance(detail, dict) else detail
)
return response.content
async def _post_inner_api_json(self, path: str, payload: Mapping[str, Any]) -> object:
response = await self._request("POST", path, json=dict(payload))
return self._normalize_json_payload(
response, invalid_json_detail="Dify API config request returned invalid JSON"
)
async def _patch_inner_api_json(self, path: str, payload: Mapping[str, Any]) -> object:
response = await self._request("PATCH", path, json=dict(payload))
return self._normalize_json_payload(
response, invalid_json_detail="Dify API config request returned invalid JSON"
)
async def _put_inner_api_json(self, path: str, payload: Mapping[str, Any]) -> object:
response = await self._request("PUT", path, json=dict(payload))
return self._normalize_json_payload(
response, invalid_json_detail="Dify API config request returned invalid JSON"
)
async def _request(
self,
method: str,
path: str,
*,
params: Mapping[str, str] | None = None,
json: Mapping[str, Any] | None = None,
) -> httpx.Response:
url = f"{self.inner_api_url.rstrip('/')}{path}"
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client:
try:
return await client.request(
method,
url,
params=dict(params or {}),
json=dict(json or {}) if json is not None else None,
headers={"X-Inner-Api-Key": self.inner_api_key},
)
except httpx.TimeoutException as exc:
raise AgentStubConfigRequestError(504, "Dify API config request timed out") from exc
except httpx.RequestError as exc:
raise AgentStubConfigRequestError(502, f"Dify API config request failed: {exc}") from exc
@staticmethod
def _normalize_json_payload(response: httpx.Response, *, invalid_json_detail: str) -> object:
try:
payload = response.json()
except ValueError as exc:
raise AgentStubConfigRequestError(502, invalid_json_detail) from exc
if response.is_error:
detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
raise AgentStubConfigRequestError(response.status_code, detail)
return payload
__all__ = [
"AgentStubConfigRequestError",
"AgentStubConfigRequestHandler",
"DifyApiAgentStubConfigRequestHandler",
]
@@ -23,6 +23,7 @@ def create_agent_stub_app(settings: ServerSettings | None = None) -> FastAPI:
create_agent_stub_router(
token_codec=resolved_settings.create_agent_stub_token_codec(),
file_request_handler=resolved_settings.create_agent_stub_file_request_handler(),
config_request_handler=resolved_settings.create_agent_stub_config_request_handler(),
drive_request_handler=resolved_settings.create_agent_stub_drive_request_handler(),
)
)
@@ -1,4 +1,10 @@
"""Shared Agent Stub control-plane service used by HTTP and gRPC transports."""
"""Shared Agent Stub control-plane service used by HTTP and gRPC transports.
This layer owns the authenticated control-plane delegation for file, config,
and drive operations. Transport adapters validate transport DTOs first, then
call into this service so auth, handler lookup, and error mapping stay shared
across HTTP and gRPC.
"""
from __future__ import annotations
@@ -8,6 +14,9 @@ from uuid import uuid4
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConnectResponse,
AgentStubConfigManifestResponse,
AgentStubConfigPushRequest,
AgentStubConfigPushResponse,
AgentStubDriveCommitRequest,
AgentStubDriveCommitResponse,
AgentStubDriveManifestResponse,
@@ -16,6 +25,7 @@ from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubFileUploadRequest,
AgentStubFileUploadResponse,
)
from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestError, AgentStubConfigRequestHandler
from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestError, AgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal, AgentStubTokenCodec, AgentStubTokenError
@@ -47,11 +57,12 @@ class AgentStubControlPlaneService:
HTTP and gRPC adapters validate or decode transport payloads before calling
this service, so this layer focuses only on shared auth, connection-id
generation, plus file and drive request delegation.
generation, plus file, config, and drive request delegation.
"""
token_codec: AgentStubTokenCodec | None
file_request_handler: AgentStubFileRequestHandler | None = None
config_request_handler: AgentStubConfigRequestHandler | None = None
drive_request_handler: AgentStubDriveRequestHandler | None = None
connection_id_factory: Callable[[], str] = field(default=lambda: str(uuid4()))
@@ -107,6 +118,96 @@ class AgentStubControlPlaneService:
except AgentStubDriveRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def get_config_manifest(
self,
*,
authorization: str | None,
) -> AgentStubConfigManifestResponse:
principal = self._authenticate(authorization)
handler = self._require_config_request_handler()
try:
return await handler.manifest(principal=principal)
except AgentStubConfigRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def pull_config_skill(
self,
*,
name: str,
authorization: str | None,
) -> bytes:
principal = self._authenticate(authorization)
handler = self._require_config_request_handler()
try:
return await handler.pull_skill(principal=principal, name=name)
except AgentStubConfigRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def inspect_config_skill(
self,
*,
name: str,
authorization: str | None,
) -> dict[str, object]:
principal = self._authenticate(authorization)
handler = self._require_config_request_handler()
try:
return await handler.inspect_skill(principal=principal, name=name)
except AgentStubConfigRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def pull_config_file(
self,
*,
name: str,
authorization: str | None,
) -> bytes:
principal = self._authenticate(authorization)
handler = self._require_config_request_handler()
try:
return await handler.pull_file(principal=principal, name=name)
except AgentStubConfigRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def push_config(
self,
*,
request: AgentStubConfigPushRequest,
authorization: str | None,
) -> AgentStubConfigPushResponse:
principal = self._authenticate(authorization)
handler = self._require_config_request_handler()
try:
return await handler.push(principal=principal, request=request)
except AgentStubConfigRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def update_config_env(
self,
*,
env_text: str,
authorization: str | None,
) -> dict[str, object]:
principal = self._authenticate(authorization)
handler = self._require_config_request_handler()
try:
return await handler.update_env(principal=principal, env_text=env_text)
except AgentStubConfigRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def update_config_note(
self,
*,
note: str,
authorization: str | None,
) -> dict[str, object]:
principal = self._authenticate(authorization)
handler = self._require_config_request_handler()
try:
return await handler.update_note(principal=principal, note=note)
except AgentStubConfigRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def commit_drive(
self,
*,
@@ -135,6 +236,11 @@ class AgentStubControlPlaneService:
raise AgentStubConfigurationError(503, "Agent Stub file API is not configured")
return self.file_request_handler
def _require_config_request_handler(self) -> AgentStubConfigRequestHandler:
if self.config_request_handler is None:
raise AgentStubConfigurationError(503, "Agent Stub config API is not configured")
return self.config_request_handler
def _require_drive_request_handler(self) -> AgentStubDriveRequestHandler:
if self.drive_request_handler is None:
raise AgentStubConfigurationError(503, "Agent Stub drive API is not configured")
@@ -12,6 +12,7 @@ from __future__ import annotations
from fastapi import APIRouter
from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestHandler
from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler
from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router
@@ -23,9 +24,15 @@ def create_agent_stub_router(
token_codec: AgentStubTokenCodec | None,
file_request_handler: AgentStubFileRequestHandler | None = None,
drive_request_handler: AgentStubDriveRequestHandler | None = None,
config_request_handler: AgentStubConfigRequestHandler | None = None,
) -> APIRouter:
"""Build the embeddable stub router from pre-built server dependencies."""
return create_agent_stub_http_router(token_codec, file_request_handler, drive_request_handler)
return create_agent_stub_http_router(
token_codec,
file_request_handler,
drive_request_handler,
config_request_handler,
)
__all__ = ["create_agent_stub_router"]
@@ -2,17 +2,22 @@
The router is a thin HTTP adapter around ``AgentStubControlPlaneService``. It
keeps FastAPI-specific request parsing and HTTPException translation here while
sharing auth, DTO validation, connection-id generation, and file/drive
sharing auth, DTO validation, connection-id generation, and file/config/drive
delegation with the gRPC transport.
"""
from __future__ import annotations
from fastapi import APIRouter, Header, HTTPException
from fastapi import APIRouter, Header, HTTPException, Response
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConnectRequest,
AgentStubConnectResponse,
AgentStubConfigEnvUpdateRequest,
AgentStubConfigManifestResponse,
AgentStubConfigNoteUpdateRequest,
AgentStubConfigPushRequest,
AgentStubConfigPushResponse,
AgentStubDriveCommitRequest,
AgentStubDriveCommitResponse,
AgentStubDriveManifestResponse,
@@ -21,6 +26,7 @@ from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubFileUploadRequest,
AgentStubFileUploadResponse,
)
from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestHandler
from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler
from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneError, AgentStubControlPlaneService
@@ -31,10 +37,13 @@ def create_agent_stub_http_router(
token_codec: AgentStubTokenCodec | None,
file_request_handler: AgentStubFileRequestHandler | None = None,
drive_request_handler: AgentStubDriveRequestHandler | None = None,
config_request_handler: AgentStubConfigRequestHandler | None = None,
) -> APIRouter:
"""Create HTTP routes bound to the application's Agent Stub dependencies."""
router = APIRouter(prefix="/agent-stub", tags=["agent-stub"])
service = AgentStubControlPlaneService(token_codec, file_request_handler, drive_request_handler)
service = AgentStubControlPlaneService(
token_codec, file_request_handler, config_request_handler, drive_request_handler
)
@router.post("/connections", response_model=AgentStubConnectResponse)
async def create_connection(
@@ -67,6 +76,77 @@ def create_agent_stub_http_router(
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.get("/config/manifest", response_model=AgentStubConfigManifestResponse)
async def get_config_manifest(
authorization: str | None = Header(default=None, alias="Authorization"),
) -> AgentStubConfigManifestResponse:
try:
return await service.get_config_manifest(authorization=authorization)
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.get("/config/skills/{name}/pull")
async def pull_config_skill(
name: str,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> Response:
try:
payload = await service.pull_config_skill(name=name, authorization=authorization)
return Response(content=payload, media_type="application/zip")
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.get("/config/skills/{name}/inspect")
async def inspect_config_skill(
name: str,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> dict[str, object]:
try:
return await service.inspect_config_skill(name=name, authorization=authorization)
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.get("/config/files/{name}/pull")
async def pull_config_file(
name: str,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> Response:
try:
payload = await service.pull_config_file(name=name, authorization=authorization)
return Response(content=payload, media_type="application/octet-stream")
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.post("/config/push", response_model=AgentStubConfigPushResponse)
async def push_config(
request: AgentStubConfigPushRequest,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> AgentStubConfigPushResponse:
try:
return await service.push_config(request=request, authorization=authorization)
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.patch("/config/env")
async def update_config_env(
request: AgentStubConfigEnvUpdateRequest,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> dict[str, object]:
try:
return await service.update_config_env(env_text=request.env_text, authorization=authorization)
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.put("/config/note")
async def update_config_note(
request: AgentStubConfigNoteUpdateRequest,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> dict[str, object]:
try:
return await service.update_config_note(note=request.note, authorization=authorization)
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.get("/drive/manifest", response_model=AgentStubDriveManifestResponse)
async def get_drive_manifest(
prefix: str = "",
@@ -0,0 +1,19 @@
"""Client-safe exports for the Dify config runtime catalog DTOs."""
from dify_agent.layers.config.configs import (
DIFY_CONFIG_LAYER_TYPE_ID,
DifyConfigFileConfig,
DifyConfigLayerConfig,
DifyConfigRuntimeState,
DifyConfigSkillConfig,
DifyConfigVersionConfig,
)
__all__ = [
"DIFY_CONFIG_LAYER_TYPE_ID",
"DifyConfigFileConfig",
"DifyConfigLayerConfig",
"DifyConfigRuntimeState",
"DifyConfigSkillConfig",
"DifyConfigVersionConfig",
]
@@ -0,0 +1,86 @@
"""Client-safe DTOs for the Dify config declaration layer."""
from typing import ClassVar, Final, Literal
from pydantic import BaseModel, ConfigDict, Field
from agenton.layers import LayerConfig
DIFY_CONFIG_LAYER_TYPE_ID: Final[str] = "dify.config"
class DifyConfigVersionConfig(BaseModel):
"""Agent config version metadata visible to the runtime prompt."""
id: str | None = None
kind: Literal["snapshot", "draft", "build_draft"] = "snapshot"
writable: bool = False
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class DifyConfigSkillConfig(BaseModel):
"""Prompt-safe summary of one Agent Soul config skill."""
name: str
description: str = ""
size: int | None = None
mime_type: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class DifyConfigFileConfig(BaseModel):
"""Prompt-safe summary of one Agent Soul config file."""
name: str
size: int | None = None
mime_type: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class DifyConfigLayerConfig(LayerConfig):
"""Agent Soul config context plus eager-pull instructions for prompt mentions."""
agent_id: str | None = None
config_version: DifyConfigVersionConfig | None = None
skills: list[DifyConfigSkillConfig] = Field(default_factory=list)
files: list[DifyConfigFileConfig] = Field(default_factory=list)
env_keys: list[str] = Field(default_factory=list)
note: str = ""
mentioned_skill_names: list[str] = Field(default_factory=list)
mentioned_file_names: list[str] = Field(default_factory=list)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class DifyConfigRuntimeState(BaseModel):
"""Serializable config-layer values computed once during context entry.
The ``push_spec_*`` fields are compatibility leftovers from the removed root
JSON-spec config mutation workflow. This change keeps them in the runtime-state
schema to avoid snapshot churn, but new code should treat them as inert
compatibility fields rather than active prompt data.
"""
pulled_skill_outputs: dict[str, str] = Field(default_factory=dict)
pulled_file_outputs: dict[str, str] = Field(default_factory=dict)
config_context_json: str = ""
config_cli_help: dict[str, str] = Field(default_factory=dict)
push_spec_semantics: str = ""
push_spec_json_schema: str = ""
push_spec_example: str = ""
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True)
__all__ = [
"DIFY_CONFIG_LAYER_TYPE_ID",
"DifyConfigFileConfig",
"DifyConfigLayerConfig",
"DifyConfigRuntimeState",
"DifyConfigSkillConfig",
"DifyConfigVersionConfig",
]
@@ -0,0 +1,227 @@
"""Runtime Dify config layer with shell-backed eager pulls."""
from __future__ import annotations
import asyncio
import shlex
from dataclasses import dataclass
from typing import ClassVar
from typing_extensions import Self, override
from agenton.layers import LayerDeps, PlainLayer
from dify_agent.agent_stub.cli.main import render_agent_stub_cli_help
from dify_agent.layers.config.configs import (
DIFY_CONFIG_LAYER_TYPE_ID,
DifyConfigLayerConfig,
DifyConfigRuntimeState,
)
from dify_agent.layers.shell.layer import DifyShellLayer
_CONFIG_CONTEXT_HEADING = "Agent config context from the current Agent Soul:"
_CONFIG_CLI_USAGE_PROMPT = """Agent config CLI usage is available inside shell jobs. The command help below is generated
from the same `dify-agent` CLI definitions available in shell jobs.
Local edits to config files, skills, env, or notes are not saved by themselves. Config changes are saved only by a
matching resource mutation command. Those commands are available only when the Agent config context reports
`config_version.kind` as `build_draft` and `config_version.writable` as true."""
_CONFIG_CLI_HELP_COMMANDS: dict[str, tuple[str, ...]] = {
"dify-agent config --help": ("config",),
"dify-agent config manifest --help": ("config", "manifest"),
"dify-agent config skills pull --help": ("config", "skills", "pull"),
"dify-agent config files pull --help": ("config", "files", "pull"),
"dify-agent config env pull --help": ("config", "env", "pull"),
"dify-agent config note pull --help": ("config", "note", "pull"),
}
_CONFIG_CLI_MUTATION_HELP_COMMANDS: dict[str, tuple[str, ...]] = {
"dify-agent config note push --help": ("config", "note", "push"),
"dify-agent config env push --help": ("config", "env", "push"),
"dify-agent config files push --help": ("config", "files", "push"),
"dify-agent config files delete --help": ("config", "files", "delete"),
"dify-agent config skills push --help": ("config", "skills", "push"),
"dify-agent config skills delete --help": ("config", "skills", "delete"),
}
_CONFIG_CONTEXT_EXCLUDE = {"mentioned_skill_names": True, "mentioned_file_names": True}
class DifyConfigLayerError(RuntimeError):
"""Raised when one eager-pull config operation fails."""
class DifyConfigDeps(LayerDeps):
shell: DifyShellLayer # pyright: ignore[reportUninitializedInstanceVariable]
@dataclass(slots=True)
class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConfigRuntimeState]):
"""Config runtime layer that materializes prompt-mentioned targets via shell."""
type_id: ClassVar[str | None] = DIFY_CONFIG_LAYER_TYPE_ID
config: DifyConfigLayerConfig
@classmethod
@override
def from_config(cls, config: DifyConfigLayerConfig) -> Self:
return cls(config=DifyConfigLayerConfig.model_validate(config))
@property
@override
def prefix_prompts(self) -> list[str]:
return [self.build_prompt_context()]
@property
@override
def suffix_prompts(self) -> list[str]:
return [self.build_suffix_prompt()]
@override
async def on_context_create(self) -> None:
await self._initialize_context()
@override
async def on_context_resume(self) -> None:
return None
async def _initialize_context(self) -> None:
self._initialize_runtime_prompt_state()
await self._pull_mentioned_targets()
def _initialize_runtime_prompt_state(self) -> None:
command_paths = dict(_CONFIG_CLI_HELP_COMMANDS)
if self._config_writable:
command_paths.update(_CONFIG_CLI_MUTATION_HELP_COMMANDS)
self.runtime_state.config_context_json = self._format_config_context_json()
self.runtime_state.config_cli_help = {
command: render_agent_stub_cli_help(args) for command, args in command_paths.items()
}
self.runtime_state.push_spec_semantics = ""
self.runtime_state.push_spec_json_schema = ""
self.runtime_state.push_spec_example = ""
def build_prompt_context(self) -> str:
sections: list[str] = []
loaded_skill_sections = []
for name in self.config.mentioned_skill_names:
output = self.runtime_state.pulled_skill_outputs.get(name)
if output is None:
continue
loaded_skill_sections.append(f"Name: {name}\nPull output:\n{output}")
if loaded_skill_sections:
sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections))
mentioned_file_sections = [
f"Name: {name}\nPull output:\n{self.runtime_state.pulled_file_outputs[name]}"
for name in self.config.mentioned_file_names
if name in self.runtime_state.pulled_file_outputs
]
if mentioned_file_sections:
sections.append("Mentioned files pulled locally:\n\n" + "\n\n".join(mentioned_file_sections))
return "\n\n".join(section for section in sections if section)
def build_suffix_prompt(self) -> str:
sections: list[str] = []
if self.runtime_state.config_context_json:
sections.append(f"{_CONFIG_CONTEXT_HEADING}\n{self.runtime_state.config_context_json}")
usage_lines = [_CONFIG_CLI_USAGE_PROMPT]
if cli_help := self._format_config_cli_help():
usage_lines.append(cli_help)
sections.append("\n".join(usage_lines))
return "\n\n".join(section for section in sections if section)
@property
def _config_writable(self) -> bool:
return self.config.config_version is not None and self.config.config_version.writable
def _format_config_cli_help(self) -> str:
commands = list(_CONFIG_CLI_HELP_COMMANDS)
if self._config_writable:
commands.extend(_CONFIG_CLI_MUTATION_HELP_COMMANDS)
command_sections = [
f"$ {command}\n{self.runtime_state.config_cli_help[command]}"
for command in commands
if command in self.runtime_state.config_cli_help
]
if not command_sections:
return ""
return "Agent config CLI help:\n" + "\n\n".join(command_sections)
def _format_config_context_json(self) -> str:
return self.config.model_dump_json(exclude=_CONFIG_CONTEXT_EXCLUDE, exclude_none=True)
async def _pull_mentioned_targets(self) -> None:
self.runtime_state.pulled_skill_outputs = {}
self.runtime_state.pulled_file_outputs = {}
if not self.config.mentioned_skill_names and not self.config.mentioned_file_names:
return
tasks = [
*(self._pull_mentioned_skill(name) for name in self.config.mentioned_skill_names),
*(self._pull_mentioned_file(name) for name in self.config.mentioned_file_names),
]
await asyncio.gather(*tasks)
async def _pull_mentioned_skill(self, name: str) -> None:
result = await self.deps.shell.run_remote_script(
self._build_shell_skill_pull_script(name),
inject_agent_stub_env=True,
)
if result.exit_code != 0:
raise DifyConfigLayerError(
"config mentioned skill pull failed in shell: "
f"{result.status} exit_code={result.exit_code}\n{result.output}"
)
if not result.output_complete:
reason = result.incomplete_reason or "unknown"
raise DifyConfigLayerError(
f"config mentioned skill pull output was incomplete before the payload finished: {reason}"
)
output = result.output.strip()
if not output:
raise DifyConfigLayerError(f"missing pull output for mentioned config skill {name}")
self.runtime_state.pulled_skill_outputs = {
**self.runtime_state.pulled_skill_outputs,
name: output,
}
async def _pull_mentioned_file(self, name: str) -> None:
result = await self.deps.shell.run_remote_script(
self._build_shell_file_pull_script(name),
inject_agent_stub_env=True,
)
if result.exit_code != 0:
raise DifyConfigLayerError(
"config mentioned file pull failed in shell: "
f"{result.status} exit_code={result.exit_code}\n{result.output}"
)
if not result.output_complete:
reason = result.incomplete_reason or "unknown"
raise DifyConfigLayerError(
f"config mentioned file pull output was incomplete before the payload finished: {reason}"
)
output = result.output.strip()
if not output:
raise DifyConfigLayerError(f"missing pull output for mentioned config file {name}")
self.runtime_state.pulled_file_outputs = {
**self.runtime_state.pulled_file_outputs,
name: output,
}
def _build_shell_skill_pull_script(self, name: str) -> str:
lines = [
"set -eu",
f"dify-agent config skills pull {shlex.quote(name)}",
]
return "\n".join(lines)
def _build_shell_file_pull_script(self, name: str) -> str:
lines = [
"set -eu",
f"dify-agent config files pull {shlex.quote(name)}",
]
return "\n".join(lines)
__all__ = ["DifyConfigLayer", "DifyConfigLayerError"]
@@ -0,0 +1,15 @@
"""Client-safe exports for the Dify core-tools layer DTOs and type ids."""
from dify_agent.layers.dify_core_tools.configs import (
DIFY_CORE_TOOLS_LAYER_TYPE_ID,
DifyCoreToolConfig,
DifyCoreToolProviderType,
DifyCoreToolsLayerConfig,
)
__all__ = [
"DIFY_CORE_TOOLS_LAYER_TYPE_ID",
"DifyCoreToolConfig",
"DifyCoreToolProviderType",
"DifyCoreToolsLayerConfig",
]
@@ -0,0 +1,208 @@
"""Async client for the Dify API inner core-tools invoke endpoint."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import ClassVar
import httpx
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError
from dify_agent.layers.dify_core_tools.configs import DifyCoreToolConfig
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
class DifyCoreToolsClientError(RuntimeError):
"""Raised when the inner core-tools HTTP boundary fails."""
status_code: int | None
error_code: str | None
retryable: bool
def __init__(
self,
message: str,
*,
status_code: int | None = None,
error_code: str | None = None,
retryable: bool,
) -> None:
self.status_code = status_code
self.error_code = error_code
self.retryable = retryable
super().__init__(message)
class DifyCoreToolsClientConfigurationError(DifyCoreToolsClientError):
"""Raised for local layer/configuration precondition failures before HTTP I/O."""
class _DifyCoreToolsCaller(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 _DifyCoreToolsRequestTool(BaseModel):
"""Inner-API tool envelope.
`runtime_parameters` are API-prepared hidden/form/runtime values attached
to the tool declaration. `tool_parameters` are the live LLM/user arguments
passed by the runtime when invoking the tool.
"""
provider_type: str
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 _DifyCoreToolsInvokeRequest(BaseModel):
caller: _DifyCoreToolsCaller
tool: _DifyCoreToolsRequestTool
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class DifyCoreToolsInvokeResponse(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")
@dataclass(slots=True)
class DifyCoreToolsClient:
"""Boundary client for `POST /inner/api/agent/tools/invoke`."""
base_url: str
api_key: str = field(repr=False)
http_client: httpx.AsyncClient = field(repr=False)
def __post_init__(self) -> None:
self.base_url = self.base_url.rstrip("/")
async def invoke(
self,
*,
execution_context: DifyExecutionContextLayerConfig,
tool_config: DifyCoreToolConfig,
tool_parameters: dict[str, JsonValue],
) -> DifyCoreToolsInvokeResponse:
_validate_execution_context(execution_context)
request_payload = _DifyCoreToolsInvokeRequest(
caller=_DifyCoreToolsCaller(
tenant_id=execution_context.tenant_id,
user_id=execution_context.user_id,
user_from=execution_context.user_from,
app_id=execution_context.app_id,
invoke_from=execution_context.invoke_from,
conversation_id=execution_context.conversation_id,
workflow_id=execution_context.workflow_id,
workflow_run_id=execution_context.workflow_run_id,
node_id=execution_context.node_id,
node_execution_id=execution_context.node_execution_id,
agent_id=execution_context.agent_id,
agent_config_version_id=execution_context.agent_config_version_id,
),
tool=_DifyCoreToolsRequestTool(
provider_type=tool_config.provider_type,
provider_id=tool_config.provider_id,
tool_name=tool_config.tool_name,
credential_id=tool_config.credential_id,
tool_parameters=tool_parameters,
runtime_parameters=tool_config.runtime_parameters,
),
)
try:
response = await self.http_client.post(
f"{self.base_url}/inner/api/agent/tools/invoke",
headers={
"X-Inner-Api-Key": self.api_key,
"Content-Type": "application/json",
},
json=request_payload.model_dump(mode="json"),
)
except (httpx.InvalidURL, httpx.UnsupportedProtocol) as exc:
raise DifyCoreToolsClientError(f"Core tools are misconfigured: {exc}", retryable=False) from exc
except httpx.TimeoutException as exc:
raise DifyCoreToolsClientError("Core tool invocation timed out.", retryable=True) from exc
except httpx.RequestError as exc:
raise DifyCoreToolsClientError(f"Core tool invocation request failed: {exc}", retryable=True) from exc
if response.status_code >= 400:
raise _build_http_error(response)
try:
return DifyCoreToolsInvokeResponse.model_validate_json(response.text)
except ValidationError as exc:
raise DifyCoreToolsClientError(
"Invalid core tool response from Dify API.",
status_code=response.status_code,
error_code="invalid_response",
retryable=False,
) from exc
def _validate_execution_context(execution_context: DifyExecutionContextLayerConfig) -> None:
missing_fields = [
field_name
for field_name in ("user_id", "user_from", "app_id")
if getattr(execution_context, field_name) is None
]
if missing_fields:
missing = ", ".join(missing_fields)
raise DifyCoreToolsClientConfigurationError(
f"Missing required execution context fields: {missing}.",
error_code="missing_execution_context",
retryable=False,
)
def _build_http_error(response: httpx.Response) -> DifyCoreToolsClientError:
detail = _decode_error_detail(response)
retryable = response.status_code >= 500 or response.status_code == 429
message = detail["message"] or f"HTTP {response.status_code}"
return DifyCoreToolsClientError(
message,
status_code=response.status_code,
error_code=detail["error_code"],
retryable=retryable,
)
def _decode_error_detail(response: httpx.Response) -> dict[str, str | None]:
raw_body = response.text
try:
payload = response.json()
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
error_code = payload.get("code")
message = payload.get("message")
return {
"error_code": error_code if isinstance(error_code, str) else None,
"message": message if isinstance(message, str) and message else raw_body or f"HTTP {response.status_code}",
}
return {"error_code": None, "message": raw_body or f"HTTP {response.status_code}"}
@@ -0,0 +1,47 @@
"""Client-safe DTOs for the Dify core-tools Agenton layer.
This layer exposes API-routed tool invocations for the Dify-owned provider
families that should execute inside the API service boundary: `plugin`,
`builtin`, `api`, `workflow`, and `mcp`. Prepared parameter declarations and
LLM-facing JSON schema are still sent by the API side so the agent runtime does
not need to inspect provider internals or storage state.
"""
from __future__ import annotations
from typing import ClassVar, Final, Literal
from pydantic import ConfigDict, Field, JsonValue
from agenton.layers import LayerConfig
from dify_agent.layers.dify_plugin.configs import DifyPluginToolParameter
type DifyCoreToolProviderType = Literal["plugin", "builtin", "api", "workflow", "mcp"]
DIFY_CORE_TOOLS_LAYER_TYPE_ID: Final[str] = "dify.core.tools"
class DifyCoreToolConfig(LayerConfig):
"""Prepared API-routed tool declaration exposed to the model."""
provider_type: DifyCoreToolProviderType
provider_id: str
tool_name: str
credential_id: str | None = None
name: str | None = None
description: str | None = None
runtime_parameters: dict[str, JsonValue] = Field(default_factory=dict)
parameters: list[DifyPluginToolParameter] = Field(default_factory=list)
parameters_json_schema: dict[str, JsonValue] = Field(
default_factory=lambda: {"type": "object", "properties": {}, "required": []}
)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
class DifyCoreToolsLayerConfig(LayerConfig):
"""Public config for the Dify core-tools layer."""
tools: list[DifyCoreToolConfig] = Field(default_factory=list)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
@@ -0,0 +1,163 @@
"""Dify core-tools layer for API-routed agent-accessible tools.
This layer consumes API-prepared tool declarations for provider families that
must execute inside the Dify API service boundary. The runtime keeps the same
prepared-parameter contract as the direct plugin layer, but invocation itself
is delegated to `POST /inner/api/agent/tools/invoke` so credentials and
provider-local state stay in the API process.
"""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from typing import ClassVar
import httpx
from pydantic_ai import RunContext, Tool
from pydantic_ai.tools import ToolDefinition
from typing_extensions import Self, override
from agenton.layers import LayerDeps, PlainLayer
from dify_agent.layers.dify_core_tools.client import (
DifyCoreToolsClient,
DifyCoreToolsClientConfigurationError,
DifyCoreToolsClientError,
)
from dify_agent.layers.dify_core_tools.configs import (
DIFY_CORE_TOOLS_LAYER_TYPE_ID,
DifyCoreToolConfig,
DifyCoreToolsLayerConfig,
)
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
CORE_TOOL_STRICT = False
TEMPORARY_UNAVAILABLE_OBSERVATION = "Tool is temporarily unavailable. Please continue without it if possible."
class DifyCoreToolsDeps(LayerDeps):
"""Dependencies required by `DifyCoreToolsLayer`."""
execution_context: DifyExecutionContextLayer # pyright: ignore[reportUninitializedInstanceVariable]
@dataclass(slots=True)
class DifyCoreToolsLayer(PlainLayer[DifyCoreToolsDeps, DifyCoreToolsLayerConfig]):
"""Layer that resolves API-routed Dify tools into Pydantic AI tools."""
type_id: ClassVar[str | None] = DIFY_CORE_TOOLS_LAYER_TYPE_ID
config: DifyCoreToolsLayerConfig
inner_api_url: str
inner_api_key: str
@classmethod
@override
def from_config(cls, config: DifyCoreToolsLayerConfig) -> Self:
del config
raise TypeError("DifyCoreToolsLayer requires server-side Dify API settings and must use a provider factory.")
@classmethod
def from_config_with_settings(
cls,
config: DifyCoreToolsLayerConfig,
*,
inner_api_url: str,
inner_api_key: str,
) -> Self:
return cls(
config=DifyCoreToolsLayerConfig.model_validate(config),
inner_api_url=inner_api_url,
inner_api_key=inner_api_key,
)
async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
if http_client.is_closed:
raise RuntimeError("DifyCoreToolsLayer.get_tools() requires an open shared HTTP client.")
execution_context = self.deps.execution_context.config
client = DifyCoreToolsClient(
base_url=self.inner_api_url,
api_key=self.inner_api_key,
http_client=http_client,
)
tools: list[Tool[object]] = []
for tool_config in self.config.tools:
tools.append(
self._build_tool(
client=client,
execution_context=execution_context,
tool_config=tool_config,
)
)
return tools
@staticmethod
def _build_tool(
*,
client: DifyCoreToolsClient,
execution_context: DifyExecutionContextLayerConfig,
tool_config: DifyCoreToolConfig,
) -> Tool[object]:
tool_name = tool_config.name or tool_config.tool_name
tool_description = tool_config.description or tool_name
tool_schema = deepcopy(tool_config.parameters_json_schema)
async def invoke_tool(_ctx: RunContext[object], **tool_arguments: object) -> str:
try:
response = await client.invoke(
execution_context=execution_context,
tool_config=tool_config,
tool_parameters=tool_arguments,
)
return response.observation
except DifyCoreToolsClientConfigurationError:
return "Tool is unavailable because required execution context is missing."
except DifyCoreToolsClientError as exc:
return _tool_error_text(tool_name=tool_name, error=exc)
async def prepare_tool_definition(_ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition:
return ToolDefinition(
name=tool_def.name,
description=tool_def.description,
parameters_json_schema=tool_schema,
strict=CORE_TOOL_STRICT,
sequential=tool_def.sequential,
metadata=tool_def.metadata,
timeout=tool_def.timeout,
defer_loading=tool_def.defer_loading,
kind=tool_def.kind,
return_schema=tool_def.return_schema,
include_return_schema=tool_def.include_return_schema,
)
return Tool(
invoke_tool,
takes_ctx=True,
name=tool_name,
description=tool_description,
prepare=prepare_tool_definition,
)
def _tool_error_text(*, tool_name: str, error: DifyCoreToolsClientError) -> str:
if error.retryable:
return TEMPORARY_UNAVAILABLE_OBSERVATION
error_code = error.error_code or ""
if error_code == "app_not_found":
return "Tool is unavailable because its app context no longer exists."
if error_code == "app_tenant_mismatch":
return "Tool is unavailable because its app context is invalid."
if error_code == "agent_tool_credential_invalid":
return "Please check your tool provider credentials"
if error_code == "agent_tool_declaration_not_found":
return f"there is not a tool named {tool_name}"
if error_code == "tool_parameters_invalid":
return f"tool parameters validation error: {error}, please check your tool parameters"
if error_code == "agent_tool_invoke_failed":
return f"tool invoke error: {error}"
return f"tool invoke error: {error}"
__all__ = ["DifyCoreToolsDeps", "DifyCoreToolsLayer"]
+55 -47
View File
@@ -5,9 +5,9 @@ mentioned in the prompt. When the layer enters a run context it eagerly pulls
those mentioned skills/files through the already-active shell layer by running
the sandbox-visible ``dify-agent drive pull`` command, then contributes a
concise prompt block describing what was loaded. It also contributes a suffix
prompt with the remaining skill catalog plus ``dify-agent drive`` and
``dify-agent file`` usage so the model has concrete Agent Stub commands for
materializing drive content and workflow files.
prompt with the remaining skill catalog plus agent-visible ``dify-agent file``
usage captured from the real CLI. Drive commands remain internal for now and
are not exposed to the model.
"""
from __future__ import annotations
@@ -24,26 +24,11 @@ from dify_agent.agent_stub.protocol import agent_stub_drive_base_for_ref
from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer
_AGENT_STUB_CLI_USAGE_PROMPT = """Agent Stub CLI usage is available inside shell jobs:
Drive assets are Agent Soul versioned assets:
- List drive assets: `dify-agent drive list [REMOTE_PREFIX]`
- Pull drive assets: `dify-agent drive pull [REMOTE ...] [--to LOCAL_DIR]`
With no remote, pulls the whole visible drive. Pull overwrites local files.
Defaults to `$DIFY_AGENT_STUB_DRIVE_BASE`; use `--to .` for cwd.
`--to` is a local root; remote keys keep their path under it.
Skill archives are automatically extracted after pull.
- Push one file: `dify-agent drive push LOCAL_FILE REMOTE_PATH`
- Push a skill package: `dify-agent drive push LOCAL_DIR REMOTE_PATH --kind skill`
- Push a raw directory: `dify-agent drive push LOCAL_DIR REMOTE_PATH --kind dir`
Workflow file mappings:
- Download a mapping: `dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [--to LOCAL_DIR]`
- Or pass a mapping object: `dify-agent file download --mapping '{"transfer_method":"tool_file","reference":"..."}'`
- Upload an output file: `dify-agent file upload PATH`
Prints JSON like `{"transfer_method":"tool_file","reference":"..."}`."""
_AGENT_STUB_FILE_HELP_COMMANDS = (
"dify-agent file --help",
"dify-agent file upload --help",
"dify-agent file download --help",
)
class DifyDriveLayerError(RuntimeError):
@@ -63,6 +48,7 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim
config: DifyDriveLayerConfig
_loaded_skill_bodies: dict[str, str] = field(default_factory=dict)
_pulled_file_paths: dict[str, str] = field(default_factory=dict)
_agent_stub_cli_help: dict[str, str] = field(default_factory=dict)
@classmethod
@override
@@ -81,10 +67,12 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim
@override
async def on_context_create(self) -> None:
await self._load_agent_stub_cli_help()
await self._pull_mentioned_targets()
@override
async def on_context_resume(self) -> None:
await self._load_agent_stub_cli_help()
await self._pull_mentioned_targets()
def build_prompt_context(self) -> str:
@@ -127,20 +115,31 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim
if skill.skill_md_key not in mentioned_skill_keys
]
if other_skills:
pull_and_read_command = (
'`skill_dir="$(dify-agent drive pull <SKILL_PATH> --to /tmp/drive)"; '
+ 'printf "%s\\n" "$skill_dir"; cat "$skill_dir/SKILL.md"`'
)
sections.append(
"Other available skills:\n"
+ "\n".join(other_skills)
+ "\n\nTo use one, pull it and read its SKILL.md in one command: "
+ pull_and_read_command
+ "."
)
sections.append(_AGENT_STUB_CLI_USAGE_PROMPT)
sections.append("Other available skills:\n" + "\n".join(other_skills))
if cli_help := self._format_agent_stub_cli_help():
sections.append(cli_help)
return "\n\n".join(sections)
def _format_agent_stub_cli_help(self) -> str:
command_sections = [
f"$ {command}\n{self._agent_stub_cli_help[command]}"
for command in _AGENT_STUB_FILE_HELP_COMMANDS
if command in self._agent_stub_cli_help
]
if not command_sections:
return ""
return "Agent Stub file CLI help:\n" + "\n\n".join(command_sections)
async def _load_agent_stub_cli_help(self) -> None:
self._agent_stub_cli_help = {}
for command in _AGENT_STUB_FILE_HELP_COMMANDS:
result = await self.deps.shell.run_remote_script(command, timeout=10.0)
if result.exit_code != 0 or not result.output_complete:
continue
output = result.output.strip()
if output:
self._agent_stub_cli_help[command] = output
async def _pull_mentioned_targets(self) -> None:
self._loaded_skill_bodies = {}
self._pulled_file_paths = {}
@@ -149,21 +148,30 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim
return
script = self._build_shell_pull_script(targets=targets)
result = await self.deps.shell.run_remote_script(script, inject_agent_stub_env=True)
result = await self.deps.shell.run_remote_script_complete(script, inject_agent_stub_env=True)
if result.exit_code != 0:
raise DifyDriveLayerError(
f"drive mentioned pull failed in shell: {result.status} exit_code={result.exit_code}\n{result.output}"
"drive mentioned pull failed in shell: "
+ f"{result.status} exit_code={result.exit_code} "
+ f"output_complete={result.output_complete} "
+ f"incomplete_reason={result.incomplete_reason} "
+ f"output_path={result.output_path}\n{result.output}"
)
if result.truncated:
raise DifyDriveLayerError("drive mentioned pull output was truncated before SKILL.md content was loaded")
written_paths, skill_bodies = self._parse_shell_pull_output(result.output)
self._record_pulled_paths(written_paths)
for skill_key in self.config.mentioned_skill_keys:
body = skill_bodies.get(skill_key)
if body is None:
raise DifyDriveLayerError(f"missing pulled SKILL.md content for mentioned skill {skill_key}")
self._loaded_skill_bodies[skill_key] = body
try:
written_paths, skill_bodies = self._parse_shell_pull_output(result.output)
self._record_pulled_paths(written_paths)
for skill_key in self.config.mentioned_skill_keys:
body = skill_bodies.get(skill_key)
if body is None:
raise DifyDriveLayerError(f"missing pulled SKILL.md content for mentioned skill {skill_key}")
self._loaded_skill_bodies[skill_key] = body
except DifyDriveLayerError:
if result.output_complete:
raise
raise DifyDriveLayerError(
"drive mentioned pull output incomplete before required SKILL.md content was captured: "
+ f"reason={result.incomplete_reason} output_path={result.output_path}\n{result.output}"
) from None
def _build_shell_pull_script(self, *, targets: list[tuple[str, bool]]) -> str:
pull_targets = list(dict.fromkeys(prefix for prefix, _exact in targets))
@@ -8,6 +8,7 @@ DTO, but that runtime implementation still lives in sibling modules.
from dify_agent.layers.execution_context.configs import (
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
DifyExecutionContextAgentConfigVersionKind,
DifyExecutionContextAgentMode,
DifyExecutionContextInvokeFrom,
DifyExecutionContextLayerConfig,
@@ -16,6 +17,7 @@ from dify_agent.layers.execution_context.configs import (
__all__ = [
"DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID",
"DifyExecutionContextAgentConfigVersionKind",
"DifyExecutionContextAgentMode",
"DifyExecutionContextInvokeFrom",
"DifyExecutionContextLayerConfig",
@@ -26,6 +26,7 @@ DifyExecutionContextAgentMode: TypeAlias = Literal[
"babysit",
"fasten",
]
DifyExecutionContextAgentConfigVersionKind: TypeAlias = Literal["snapshot", "draft", "build_draft"]
DifyExecutionContextUserFrom: TypeAlias = Literal["account", "end-user"]
DifyExecutionContextInvokeFrom: TypeAlias = Literal[
"service-api",
@@ -53,6 +54,7 @@ class DifyExecutionContextLayerConfig(LayerConfig):
conversation_id: str | None = None
agent_id: str | None = None
agent_config_version_id: str | None = None
agent_config_version_kind: DifyExecutionContextAgentConfigVersionKind | None = None
agent_mode: DifyExecutionContextAgentMode
invoke_from: DifyExecutionContextInvokeFrom
trace_id: str | None = None
@@ -62,6 +64,7 @@ class DifyExecutionContextLayerConfig(LayerConfig):
__all__ = [
"DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID",
"DifyExecutionContextAgentConfigVersionKind",
"DifyExecutionContextAgentMode",
"DifyExecutionContextInvokeFrom",
"DifyExecutionContextUserFrom",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
"""Helpers for formatting shell output into bounded prompt-safe text.
The shell layer uses byte budgets, not Python character counts, because
shellctl output windows are byte-limited. These helpers keep UTF-8 boundaries
valid while producing a compact head/tail rendering for model-visible output.
"""
from __future__ import annotations
def utf8_prefix(text: str, max_bytes: int) -> str:
"""Return the longest UTF-8-safe prefix that fits within ``max_bytes``."""
if max_bytes <= 0:
return ""
return text.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore")
def utf8_suffix(text: str, max_bytes: int) -> str:
"""Return the longest UTF-8-safe suffix that fits within ``max_bytes``."""
if max_bytes <= 0:
return ""
return text.encode("utf-8")[-max_bytes:].decode("utf-8", errors="ignore")
def normalized_output_text(
head: str,
*,
tail: str | None,
output_path: str | None,
max_output_size_bytes: int,
truncated_in_middle: bool | None = None,
truncation_message: str | None = None,
) -> str:
"""Format bounded shell output with an optional truncation marker and log path."""
if truncated_in_middle is None:
truncated_in_middle = tail is not None and tail != head
if not truncated_in_middle:
return head
if truncation_message is None:
truncation_message = (
f"truncated in middle because the max output size is limited to {max_output_size_bytes} bytes"
)
parts = [
head,
f"... ({truncation_message}) ...",
]
if tail is not None:
parts.append(tail)
if output_path:
parts.append(f"(check the {output_path} for full output)")
return "\n".join(parts)
__all__ = ["normalized_output_text", "utf8_prefix", "utf8_suffix"]
+10 -3
View File
@@ -4,8 +4,8 @@ The sandbox file APIs must rebuild only the minimum runtime needed to re-enter a
prior shell session: ``dify.execution_context`` for Dify-owned identity and
``dify.shell`` for the sandbox workspace itself. ``SandboxLocator`` therefore
contains a safe composition subset plus the matching filtered session snapshot.
Credential-bearing plugin layers are intentionally excluded from persisted
runtime specs and from sandbox locators.
Credential-bearing or runtime-only tool layers are intentionally excluded from
persisted runtime specs and from sandbox locators.
"""
from __future__ import annotations
@@ -14,12 +14,19 @@ from typing import ClassVar, Literal, cast
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID
from pydantic import BaseModel, ConfigDict, Field, JsonValue
from .schemas import CreateRunRequest, RunComposition, RunLayerSpec
_SENSITIVE_LAYER_TYPES = frozenset({DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID})
_SENSITIVE_LAYER_TYPES = frozenset(
{
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
DIFY_CORE_TOOLS_LAYER_TYPE_ID,
}
)
class RuntimeLayerSpec(BaseModel):
@@ -6,25 +6,28 @@ state-free Dify structured output layer, the optional Dify ask-human layer, the
Dify execution-context layer, the stateful Dify shell layer, and the Dify
plugin/knowledge business-layer family:
- ``dify.drive`` for drive-backed skill catalog + eager pull,
- ``dify.config`` for Agent Soul-backed config assets + eager pull,
- ``dify.execution_context`` for shared tenant/user/run daemon context,
- ``dify.shell`` for shellctl-backed shell job control,
- ``dify.plugin.llm`` for plugin-backed model selection,
- ``dify.plugin.tools`` for prepared plugin tool exposure, and
- ``dify.core.tools`` for API-routed Dify tool exposure, and
- ``dify.knowledge_base`` for inner-API-backed knowledge search tools.
Public DTOs provide Dify context plus plugin/model/tool data, while server-only
plugin daemon settings and Dify API inner settings are injected through provider
factories. Optional shellctl entrypoint/auth token, client factory, and Agent
Stub URL/token issuer are injected for ``DifyShellLayer``. The resulting
``Compositor`` remains Agenton state-only at the snapshot boundary: live
resources such as HTTP clients are injected by runtime-owned providers, may be
held on active layer instances inside ``resource_context()``, and never enter
session snapshots.
factories. Optional shellctl entrypoint/auth token and Agent Stub URL/token
issuer are injected for ``DifyShellLayer``. The resulting ``Compositor``
remains Agenton state-only at the snapshot boundary: live resources such as
HTTP clients are injected by runtime-owned providers, may be held on active
layer instances inside ``resource_context()``, and never enter session
snapshots.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from pydantic_ai.messages import UserContent
@@ -34,8 +37,10 @@ from agenton_collections.layers.pydantic_ai import PydanticAIHistoryLayer
from agenton_collections.layers.plain.basic import PromptLayer
from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS
from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
from dify_agent.layers.ask_human.layer import DifyAskHumanLayer
from dify_agent.layers.config.layer import DifyConfigLayer
from dify_agent.layers.dify_core_tools.configs import DifyCoreToolsLayerConfig
from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
from dify_agent.layers.drive.layer import DifyDriveLayer
@@ -45,10 +50,13 @@ from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig
from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer
from dify_agent.layers.output.output_layer import DifyOutputLayer
from dify_agent.adapters.shell.config import ShellAdapterSettings
from dify_agent.adapters.shell.factory import create_shell_provisioner
from dify_agent.adapters.shell.factory import create_shell_provider
from dify_agent.layers.shell.configs import DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer
if TYPE_CHECKING:
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
type DifyAgentLayerProvider = LayerProvider[Any]
@@ -84,11 +92,23 @@ def create_default_layer_providers(
)
agent_stub_token_factory = build_agent_stub_token
shell_provider = (
create_shell_provider(
ShellAdapterSettings(
shell_provider="shellctl",
shellctl_entrypoint=shellctl_entrypoint,
shellctl_auth_token=shellctl_auth_token,
)
)
if shellctl_entrypoint
else None
)
return (
LayerProvider.from_layer_type(PromptLayer),
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
LayerProvider.from_layer_type(DifyOutputLayer),
LayerProvider.from_layer_type(DifyAskHumanLayer),
LayerProvider.from_layer_type(DifyConfigLayer),
LayerProvider.from_layer_type(DifyDriveLayer),
LayerProvider.from_factory(
layer_type=DifyExecutionContextLayer,
@@ -102,21 +122,21 @@ def create_default_layer_providers(
layer_type=DifyShellLayer,
create=lambda config: DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig.model_validate(config),
shell_provisioner=create_shell_provisioner(
ShellAdapterSettings(
shell_provider="shellctl",
shellctl_entrypoint=shellctl_entrypoint,
shellctl_auth_token=shellctl_auth_token,
)
)
if shellctl_entrypoint
else None,
shell_provider=shell_provider,
agent_stub_api_base_url=agent_stub_api_base_url,
agent_stub_token_factory=agent_stub_token_factory,
),
),
LayerProvider.from_layer_type(DifyPluginLLMLayer),
LayerProvider.from_layer_type(DifyPluginToolsLayer),
LayerProvider.from_factory(
layer_type=DifyCoreToolsLayer,
create=lambda config: DifyCoreToolsLayer.from_config_with_settings(
DifyCoreToolsLayerConfig.model_validate(config),
inner_api_url=inner_api_url,
inner_api_key=inner_api_key,
),
),
LayerProvider.from_factory(
layer_type=DifyKnowledgeBaseLayer,
create=lambda config: DifyKnowledgeBaseLayer.from_config_with_settings(
+2
View File
@@ -40,6 +40,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
resolved_settings = settings or ServerSettings()
agent_stub_token_codec = resolved_settings.create_agent_stub_token_codec()
agent_stub_file_request_handler = resolved_settings.create_agent_stub_file_request_handler()
agent_stub_config_request_handler = resolved_settings.create_agent_stub_config_request_handler()
agent_stub_drive_request_handler = resolved_settings.create_agent_stub_drive_request_handler()
layer_providers = create_default_layer_providers(
plugin_daemon_url=resolved_settings.plugin_daemon_url,
@@ -111,6 +112,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
create_agent_stub_router(
token_codec=agent_stub_token_codec,
file_request_handler=agent_stub_file_request_handler,
config_request_handler=agent_stub_config_request_handler,
drive_request_handler=agent_stub_drive_request_handler,
)
)
@@ -4,7 +4,7 @@ Unlike the removed workspace inspector, this service never talks to shellctl
directly and never reads sandbox files outside the shell layer. It rebuilds a
minimal compositor from ``SandboxLocator``, enters the saved
``execution_context`` + ``shell`` layers, and executes fixed scripts through
``DifyShellLayer.run_remote_script()``.
``DifyShellLayer.run_remote_script_complete()``.
The scripts still frame their structured payloads with a PTY-safe
base64-between-sentinels envelope. shellctl jobs are tmux-backed, so raw JSON can
@@ -22,7 +22,8 @@ import textwrap
from dataclasses import dataclass
from typing import TypeVar, cast
from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult
from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer
from dify_agent.layers.shell.output_text import utf8_suffix
from dify_agent.protocol import (
SandboxListRequest,
SandboxListResponse,
@@ -42,6 +43,7 @@ _READ_TIMEOUT_SECONDS = 15.0
_UPLOAD_TIMEOUT_SECONDS = 30.0
_OUTPUT_BEGIN = "<<<DIFY_SANDBOX_BEGIN>>>"
_OUTPUT_END = "<<<DIFY_SANDBOX_END>>>"
_SHELL_RESULT_OUTPUT_TAIL_BYTES = 8 * 1024
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel)
_LIST_SCRIPT = """
@@ -294,7 +296,7 @@ class SandboxFileService:
async with compositor.enter(configs=layer_configs, session_snapshot=locator.session_snapshot) as run:
run.suspend_on_exit()
shell_layer = run.get_layer("shell", DifyShellLayer)
result = await shell_layer.run_remote_script(
result = await shell_layer.run_remote_script_complete(
_build_python_script_command(script_source=script_source, args=args),
timeout=timeout,
inject_agent_stub_env=inject_agent_stub_env,
@@ -328,16 +330,23 @@ def _build_python_script_command(*, script_source: str, args: list[str]) -> str:
return f"python3 - {quoted_args} <<'PY'\n{script}\nPY"
def _decode_sandbox_payload(result: RemoteCommandResult) -> dict[str, object]:
def _decode_sandbox_payload(result: CompleteRemoteCommandResult) -> dict[str, object]:
if result.exit_code not in (0, None):
raise SandboxFileError(
"sandbox_command_failed",
f"sandbox command exited with code {result.exit_code}: {_output_tail(result.output)!r}",
"sandbox command exited with code " + f"{result.exit_code}: {_shell_result_details(result)}",
status_code=502,
)
begin = result.output.find(_OUTPUT_BEGIN)
end = result.output.find(_OUTPUT_END, begin + len(_OUTPUT_BEGIN)) if begin != -1 else -1
if begin == -1 or end == -1:
if not result.output_complete:
raise SandboxFileError(
"sandbox_command_failed",
"sandbox command output incomplete before framed payload was captured: "
+ _shell_result_details(result),
status_code=502,
)
raise SandboxFileError(
"sandbox_command_failed",
"sandbox command returned no framed payload",
@@ -349,12 +358,25 @@ def _decode_sandbox_payload(result: RemoteCommandResult) -> dict[str, object]:
decoded = base64.b64decode(compact, validate=True)
loaded = cast(object, json.loads(decoded.decode("utf-8")))
except (binascii.Error, ValueError) as exc:
if not result.output_complete:
raise SandboxFileError(
"sandbox_command_failed",
"sandbox command output incomplete while decoding framed payload: " + _shell_result_details(result),
status_code=502,
) from exc
raise SandboxFileError(
"sandbox_command_failed",
f"sandbox command returned invalid framed payload: {exc}",
status_code=502,
) from exc
if not isinstance(loaded, dict):
if not result.output_complete:
raise SandboxFileError(
"sandbox_command_failed",
"sandbox command output incomplete while validating framed payload object: "
+ _shell_result_details(result),
status_code=502,
)
raise SandboxFileError(
"sandbox_command_failed", "sandbox command returned a non-object payload", status_code=502
)
@@ -379,9 +401,22 @@ def _decode_sandbox_payload(result: RemoteCommandResult) -> dict[str, object]:
return payload
def _output_tail(output: str) -> str:
stripped = output.strip()
return stripped[-200:]
def _shell_result_details(result: CompleteRemoteCommandResult) -> str:
details = (
f"output_complete={result.output_complete} "
+ f"incomplete_reason={result.incomplete_reason} "
+ f"output_path={result.output_path}"
)
if not result.output:
return details
return details + "\n" + _bounded_output_tail(result.output)
def _bounded_output_tail(output: str) -> str:
tail = utf8_suffix(output, _SHELL_RESULT_OUTPUT_TAIL_BYTES)
if tail == output:
return output
return f"... (showing last {_SHELL_RESULT_OUTPUT_TAIL_BYTES} bytes of raw output) ...\n{tail}"
def _validate_response_model(
@@ -18,6 +18,7 @@ from pydantic import AnyHttpUrl, Field, TypeAdapter, field_validator, model_vali
from pydantic_settings import BaseSettings, SettingsConfigDict
from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_api_base_url, parse_agent_stub_endpoint
from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler
from dify_agent.agent_stub.server.agent_stub_drive import DifyApiAgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler
from dify_agent.agent_stub.server.grpc_bind import normalize_agent_stub_grpc_bind_address
@@ -145,6 +146,16 @@ class ServerSettings(BaseSettings):
inner_api_key=self.inner_api_key,
)
def create_agent_stub_config_request_handler(self) -> DifyApiAgentStubConfigRequestHandler | None:
"""Return the Dify API config bridge when both Dify API settings are configured."""
if self.inner_api_key is None:
return None
return DifyApiAgentStubConfigRequestHandler(
inner_api_url=self.inner_api_url,
inner_api_key=self.inner_api_key,
timeout=self.create_outbound_http_timeout(),
)
def create_agent_stub_drive_request_handler(self) -> DifyApiAgentStubDriveRequestHandler | None:
"""Return the Dify API drive bridge when both Dify API settings are configured.
@@ -1,14 +1,9 @@
"""Local tests for the shellctl shell adapter and env-driven provider factory.
"""Local tests for the shellctl shell adapter and env-driven provider factory."""
These exercise the provider-agnostic boundary contract (provision/execute/wait,
file transfer, optional input/interrupt) against a fake shellctl client, plus the
``DIFY_AGENT_SHELL_PROVIDER`` selection in the factory. They avoid the private
``shell-session-manager`` package by injecting a structural fake client.
"""
from __future__ import annotations
import asyncio
import base64
import secrets
from collections.abc import Callable
from dataclasses import dataclass, field
@@ -16,37 +11,30 @@ import pytest
from dify_agent.adapters.shell import shellctl
from dify_agent.adapters.shell.config import ShellAdapterSettings
from dify_agent.adapters.shell.factory import create_shell_provisioner
from dify_agent.adapters.shell.protocols import (
ShellEnvironmentDescriptor,
)
from dify_agent.adapters.shell.shellctl import (
ShellctlEnvironmentDescriptor,
ShellctlProvisioner,
ShellFileTransferError,
ShellProvisionError,
)
_SESSION_HEX = "deadbeefdeadbeef"
_WORKSPACE_CWD = f"~/workspace/{_SESSION_HEX}"
from dify_agent.adapters.shell.factory import create_shell_provider
from dify_agent.adapters.shell.protocols import ShellCommandResult
from dify_agent.adapters.shell.shellctl import ShellFileTransferError, ShellProviderError, ShellctlProvider
@dataclass(slots=True)
class _Job:
job_id: str
status: str = "running"
done: bool = True
output: str = ""
offset: int = 0
truncated: bool = False
exit_code: int | None = 0
output_path: str | None = "/tmp/output.log"
@dataclass(slots=True)
class _Status:
job_id: str
status: str = "terminated"
done: bool = True
offset: int = 0
exit_code: int | None = 0
exit_code: int | None = 130
@dataclass(slots=True)
@@ -54,278 +42,290 @@ class _RunCall:
script: str
cwd: str | None
env: dict[str, str] | None
timeout: float
type _RunHandler = Callable[[str, str | None, dict[str, str] | None], _Job]
type _WaitHandler = Callable[[str, int], _Job]
type _InputHandler = Callable[[str, str, int], _Job]
type _TerminateHandler = Callable[[str], _Status]
type _RunHandler = Callable[[str, str | None, dict[str, str] | None, float], _Job]
type _WaitHandler = Callable[[str, int, float], _Job]
type _InputHandler = Callable[[str, str, int, float], _Job]
type _TerminateHandler = Callable[[str, float], _Status]
@dataclass(slots=True)
class FakeShellctlClient:
"""Structural shellctl client double recording calls and replaying handlers."""
run_handler: _RunHandler | None = None
wait_handler: _WaitHandler | None = None
input_handler: _InputHandler | None = None
tail_handler: Callable[[str], _Job] | None = None
terminate_handler: _TerminateHandler | None = None
run_calls: list[_RunCall] = field(default_factory=list)
wait_calls: list[tuple[str, int]] = field(default_factory=list)
input_calls: list[tuple[str, str, int]] = field(default_factory=list)
wait_calls: list[tuple[str, int, float]] = field(default_factory=list)
input_calls: list[tuple[str, str, int, float]] = field(default_factory=list)
terminate_calls: list[tuple[str, float]] = field(default_factory=list)
delete_calls: list[str] = field(default_factory=list)
delete_calls: list[tuple[str, bool, float | None]] = field(default_factory=list)
closed: bool = False
async def run(self, script, *, cwd=None, env=None, timeout=30.0):
del timeout
self.run_calls.append(_RunCall(script=script, cwd=cwd, env=env))
self.run_calls.append(_RunCall(script=script, cwd=cwd, env=env, timeout=timeout))
if self.run_handler is not None:
return self.run_handler(script, cwd, env)
return _Job(job_id="job", done=True, exit_code=0)
return self.run_handler(script, cwd, env, timeout)
return _Job(job_id="job", status="exited", done=True, exit_code=0)
async def wait(self, job_id, *, offset, timeout=30.0):
del timeout
self.wait_calls.append((job_id, offset))
self.wait_calls.append((job_id, offset, timeout))
if self.wait_handler is not None:
return self.wait_handler(job_id, offset)
return _Job(job_id=job_id, done=True, offset=offset, exit_code=0)
return self.wait_handler(job_id, offset, timeout)
return _Job(job_id=job_id, status="exited", done=True, offset=offset, exit_code=0)
async def input(self, job_id, text, *, offset, timeout=30.0):
del timeout
self.input_calls.append((job_id, text, offset))
self.input_calls.append((job_id, text, offset, timeout))
if self.input_handler is not None:
return self.input_handler(job_id, text, offset)
return _Job(job_id=job_id, done=True, offset=offset, exit_code=0)
return self.input_handler(job_id, text, offset, timeout)
return _Job(job_id=job_id, status="exited", done=True, offset=offset, exit_code=0)
async def tail(self, job_id):
if self.tail_handler is not None:
return self.tail_handler(job_id)
return _Job(job_id=job_id, status="exited", done=True, output="", exit_code=0)
async def terminate(self, job_id, grace_seconds=10.0):
self.terminate_calls.append((job_id, grace_seconds))
if self.terminate_handler is not None:
return self.terminate_handler(job_id)
return _Status(job_id=job_id, done=True, exit_code=130)
return self.terminate_handler(job_id, grace_seconds)
return _Status(job_id=job_id)
async def delete(self, job_id, *, force=False):
del force
self.delete_calls.append(job_id)
async def delete(self, job_id, *, force=False, grace_seconds=None):
self.delete_calls.append((job_id, force, grace_seconds))
return None
async def close(self):
self.closed = True
@pytest.fixture(autouse=True)
def _fixed_session_id(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: _SESSION_HEX)
def _provisioner(client: FakeShellctlClient) -> ShellctlProvisioner:
return ShellctlProvisioner(client_factory=lambda: client)
def test_provision_allocates_workspace_and_execute_drains_merged_output() -> None:
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del env
if script.startswith("mkdir"):
assert cwd is None
return _Job(job_id="mkdir-job", done=True, exit_code=0)
assert cwd == _WORKSPACE_CWD
return _Job(job_id="user-job", done=False, output="par", offset=3, truncated=False, exit_code=None)
def wait_handler(job_id: str, offset: int) -> _Job:
assert job_id == "user-job"
assert offset == 3
return _Job(job_id="user-job", done=True, output="tial", offset=7, exit_code=0)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
async def scenario() -> None:
handle = await _provisioner(client).provision()
assert handle.workspace_cwd == _WORKSPACE_CWD
executor = await handle.get_executor()
result = await executor.execute("pwd", env={"FOO": "bar"})
assert result.stdout() == "partial"
assert result.stderr() == ""
assert result.exit_code() == 0
assert result.truncated() is False
asyncio.run(scenario())
assert client.run_calls[0].cwd is None
user_run = next(call for call in client.run_calls if call.script == "pwd")
assert user_run.env == {"FOO": "bar"}
# completed jobs (internal mkdir + user command) are self-cleaned.
assert "mkdir-job" in client.delete_calls
assert "user-job" in client.delete_calls
def test_execute_reports_truncated_when_output_window_cap_is_hit() -> None:
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del cwd, env
if script.startswith("mkdir"):
return _Job(job_id="mkdir-job", done=True, exit_code=0)
return _Job(job_id="user-job", done=False, output="x", offset=1, truncated=True, exit_code=None)
def wait_handler(job_id: str, offset: int) -> _Job:
return _Job(job_id=job_id, done=False, output="x", offset=offset + 1, truncated=True, exit_code=None)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
async def scenario() -> bool:
handle = await _provisioner(client).provision()
executor = await handle.get_executor()
result = await executor.execute("tail -f log")
return result.truncated()
assert asyncio.run(scenario()) is True
# a job that never completed is left intact (not deleted/forgotten).
assert "user-job" not in client.delete_calls
def test_provision_failure_closes_client_and_raises() -> None:
client = FakeShellctlClient(
run_handler=lambda _script, _cwd, _env: _Job(job_id="mkdir-job", done=True, exit_code=1)
)
async def scenario() -> None:
with pytest.raises(ShellProvisionError):
await _provisioner(client).provision()
asyncio.run(scenario())
assert client.closed is True
def test_destroy_runs_cleanup_in_default_cwd_then_closes_client() -> None:
client = FakeShellctlClient(run_handler=lambda _script, _cwd, _env: _Job(job_id="job", done=True, exit_code=0))
async def scenario() -> None:
provisioner = _provisioner(client)
handle = await provisioner.provision()
await provisioner.destroy(handle)
asyncio.run(scenario())
cleanup_call = client.run_calls[-1]
assert cleanup_call.cwd is None
assert _SESSION_HEX in cleanup_call.script and cleanup_call.script.startswith("rm -rf")
assert client.closed is True
def test_file_transfer_download_decodes_sentinel_framed_base64() -> None:
content = b"hello \x00 world"
encoded = base64.b64encode(content).decode("ascii")
framed = f"noise{shellctl._TRANSFER_BEGIN}{encoded}{shellctl._TRANSFER_END}trailing"
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del env
if script.startswith("mkdir"):
return _Job(job_id="mkdir-job", done=True, exit_code=0)
assert cwd == _WORKSPACE_CWD
return _Job(job_id="dl-job", done=True, output=framed, exit_code=0)
client = FakeShellctlClient(run_handler=run_handler)
async def scenario() -> None:
handle = await _provisioner(client).provision()
transfer = await handle.get_file_transfer()
downloaded = await transfer.download(remote_path="report.txt")
assert downloaded == content
asyncio.run(scenario())
def test_file_transfer_download_missing_file_raises() -> None:
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del cwd, env
if script.startswith("mkdir"):
return _Job(job_id="mkdir-job", done=True, exit_code=0)
return _Job(job_id="dl-job", done=True, output="", exit_code=shellctl._DOWNLOAD_MISSING_EXIT_CODE)
client = FakeShellctlClient(run_handler=run_handler)
async def scenario() -> None:
handle = await _provisioner(client).provision()
transfer = await handle.get_file_transfer()
with pytest.raises(ShellFileTransferError, match="not found"):
await transfer.download(remote_path="missing.txt")
asyncio.run(scenario())
def test_file_transfer_upload_embeds_base64_and_succeeds() -> None:
content = b"payload-bytes"
encoded = base64.b64encode(content).decode("ascii")
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del env
if script.startswith('mkdir -p "$HOME'):
return _Job(job_id="mkdir-job", done=True, exit_code=0)
assert cwd == _WORKSPACE_CWD
assert encoded in script
return _Job(job_id="ul-job", done=True, exit_code=0)
client = FakeShellctlClient(run_handler=run_handler)
async def scenario() -> None:
handle = await _provisioner(client).provision()
transfer = await handle.get_file_transfer()
await transfer.upload(content=content, remote_path="out.bin")
asyncio.run(scenario())
def test_provision_exposes_descriptor_seed() -> None:
client = FakeShellctlClient(
run_handler=lambda _script, _cwd, _env: _Job(job_id="mkdir-job", done=True, exit_code=0)
)
async def scenario() -> ShellEnvironmentDescriptor:
handle = await _provisioner(client).provision()
return handle.descriptor()
descriptor = asyncio.run(scenario())
assert isinstance(descriptor, ShellctlEnvironmentDescriptor)
assert descriptor.workspace_cwd == _WORKSPACE_CWD
assert descriptor.session_id == _SESSION_HEX
def test_reattach_rebuilds_handle_without_mkdir_and_executes_in_same_workspace() -> None:
descriptor = ShellctlEnvironmentDescriptor(workspace_cwd=_WORKSPACE_CWD, session_id=_SESSION_HEX)
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del env
assert not script.startswith("mkdir")
assert cwd == _WORKSPACE_CWD
return _Job(job_id="user-job", done=True, output="ok", offset=2, exit_code=0)
client = FakeShellctlClient(run_handler=run_handler)
async def scenario() -> str:
handle = await _provisioner(client).reattach(descriptor)
executor = await handle.get_executor()
result = await executor.execute("pwd")
return result.stdout()
assert asyncio.run(scenario()) == "ok"
# reattach must not allocate a new workspace.
assert all(not call.script.startswith("mkdir") for call in client.run_calls)
def _provider(client: FakeShellctlClient) -> ShellctlProvider:
return ShellctlProvider(entrypoint="http://shellctl", token="", client_factory=lambda: client)
def test_factory_unknown_provider_raises() -> None:
settings = ShellAdapterSettings(shell_provider="nope")
with pytest.raises(ValueError, match="Unknown shell provider"):
create_shell_provisioner(settings)
create_shell_provider(settings)
def test_factory_shellctl_requires_entrypoint() -> None:
settings = ShellAdapterSettings(shell_provider="shellctl", shellctl_entrypoint=None)
with pytest.raises(ValueError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
create_shell_provisioner(settings)
create_shell_provider(settings)
def test_factory_builds_shellctl_provisioner_from_settings() -> None:
settings = ShellAdapterSettings(
shell_provider="shellctl",
shellctl_entrypoint="http://shellctl.example",
def test_factory_builds_shellctl_provider_from_settings() -> None:
settings = ShellAdapterSettings(shell_provider="shellctl", shellctl_entrypoint="http://shellctl.example")
provider = create_shell_provider(settings)
assert isinstance(provider, ShellctlProvider)
assert provider.entrypoint == "http://shellctl.example"
assert provider.token == ""
def test_provider_create_opens_only_live_resource_and_close_closes_client() -> None:
client = FakeShellctlClient()
async def scenario() -> None:
resource = await _provider(client).create()
assert client.run_calls == []
await resource.close()
asyncio.run(scenario())
assert client.closed is True
def test_commands_forward_parameters_and_map_metadata() -> None:
client = FakeShellctlClient(
run_handler=lambda script, cwd, env, timeout: _Job(
job_id="run-job",
status="running",
done=False,
output="abc",
offset=3,
truncated=True,
exit_code=None,
output_path="/tmp/run.log",
),
wait_handler=lambda job_id, offset, timeout: _Job(
job_id=job_id,
status="running",
done=False,
output="def",
offset=6,
truncated=False,
exit_code=None,
output_path="/tmp/run.log",
),
input_handler=lambda job_id, text, offset, timeout: _Job(
job_id=job_id,
status="exited",
done=True,
output="ghi",
offset=9,
truncated=False,
exit_code=0,
output_path="/tmp/run.log",
),
tail_handler=lambda job_id: _Job(
job_id=job_id,
status="exited",
done=True,
output="tail",
offset=11,
truncated=False,
exit_code=0,
output_path="/tmp/tail.log",
),
terminate_handler=lambda job_id, grace_seconds: _Status(
job_id=job_id,
status="terminated",
done=True,
offset=12,
exit_code=130,
),
)
provisioner = create_shell_provisioner(settings)
assert isinstance(provisioner, ShellctlProvisioner)
async def scenario() -> None:
resource = await _provider(client).create()
run_result = await resource.commands.run("pwd", cwd="~/workspace/abc12ff", env={"FOO": "bar"}, timeout=2.5)
wait_result = await resource.commands.wait("run-job", offset=3, timeout=4.0)
read_result = await resource.commands.read_output("run-job", offset=6)
input_result = await resource.commands.input("run-job", "ls\n", offset=6, timeout=5.0)
interrupt_result = await resource.commands.interrupt("run-job", grace_seconds=1.5)
tail_result = await resource.commands.tail("run-job")
await resource.commands.delete("run-job", force=True, grace_seconds=2.0)
await resource.close()
assert run_result == ShellCommandResult(
job_id="run-job",
status="running",
done=False,
exit_code=None,
output="abc",
offset=3,
truncated=True,
output_path="/tmp/run.log",
)
assert wait_result.offset == 6
assert read_result.offset == 6
assert input_result.exit_code == 0
assert interrupt_result.status == "terminated"
assert tail_result.output_path == "/tmp/tail.log"
asyncio.run(scenario())
assert client.run_calls == [_RunCall(script="pwd", cwd="~/workspace/abc12ff", env={"FOO": "bar"}, timeout=2.5)]
assert client.wait_calls == [
("run-job", 3, 4.0),
("run-job", 6, 0.0),
]
assert client.input_calls == [("run-job", "ls\n", 6, 5.0)]
assert client.terminate_calls == [("run-job", 1.5)]
assert client.delete_calls == [("run-job", True, 2.0)]
def test_files_upload_and_download_still_work() -> None:
content = b"hello \x00 world"
encoded = base64.b64encode(content).decode("ascii")
client = FakeShellctlClient(
run_handler=lambda script, cwd, env, timeout: (
_Job(job_id="ul-job", status="exited", done=True, exit_code=0)
if "base64 -d" in script
else _Job(
job_id="dl-job",
status="exited",
done=True,
exit_code=0,
output=f"noise{shellctl._TRANSFER_BEGIN}{encoded}{shellctl._TRANSFER_END}tail",
)
)
)
async def scenario() -> None:
resource = await _provider(client).create()
await resource.files.upload(content=content, remote_path="out.bin", cwd="~/workspace/abc12ff")
downloaded = await resource.files.download(remote_path="report.txt", cwd="~/workspace/abc12ff")
assert downloaded == content
asyncio.run(scenario())
def test_file_transfer_timeout_is_an_end_to_end_budget(monkeypatch: pytest.MonkeyPatch) -> None:
clock = {"value": 100.0}
def fake_monotonic() -> float:
return clock["value"]
monkeypatch.setattr(shellctl.time, "monotonic", fake_monotonic)
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None, timeout: float) -> _Job:
del script, cwd, env
assert timeout == pytest.approx(5.0, rel=0, abs=0.01)
clock["value"] = 103.5
return _Job(job_id="upload-job", status="running", done=False, output="part-1", offset=6, exit_code=None)
def wait_handler(job_id: str, offset: int, timeout: float) -> _Job:
assert job_id == "upload-job"
assert offset == 6
assert timeout == pytest.approx(1.5, rel=0, abs=0.01)
return _Job(job_id=job_id, status="exited", done=True, output="part-2", offset=12, exit_code=0)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
async def scenario() -> None:
transfer = shellctl.ShellctlFileTransfer(client=client, timeout=5.0)
await transfer.upload(content=b"payload", remote_path="out.bin")
asyncio.run(scenario())
assert client.delete_calls == [("upload-job", True, None)]
def test_file_transfer_timeout_exhaustion_raises_timeout_and_still_deletes_job(
monkeypatch: pytest.MonkeyPatch,
) -> None:
clock = {"value": 100.0}
def fake_monotonic() -> float:
return clock["value"]
monkeypatch.setattr(shellctl.time, "monotonic", fake_monotonic)
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None, timeout: float) -> _Job:
del script, cwd, env
assert timeout == pytest.approx(5.0, rel=0, abs=0.01)
clock["value"] = 106.0
return _Job(job_id="upload-job", status="running", done=False, output="part-1", offset=6, exit_code=None)
client = FakeShellctlClient(run_handler=run_handler)
async def scenario() -> None:
transfer = shellctl.ShellctlFileTransfer(client=client, timeout=5.0)
with pytest.raises(ShellProviderError, match="timed out") as exc_info:
await transfer.upload(content=b"payload", remote_path="out.bin")
assert exc_info.value.code == "timeout"
asyncio.run(scenario())
assert client.delete_calls == [("upload-job", True, None)]
def test_download_missing_file_raises() -> None:
client = FakeShellctlClient(
run_handler=lambda script, cwd, env, timeout: _Job(
job_id="dl-job",
status="exited",
done=True,
output="",
exit_code=shellctl._DOWNLOAD_MISSING_EXIT_CODE,
)
)
async def scenario() -> None:
resource = await _provider(client).create()
with pytest.raises(ShellFileTransferError, match="not found"):
await resource.files.download(remote_path="missing.txt")
asyncio.run(scenario())
@@ -0,0 +1,390 @@
from __future__ import annotations
import io
import zipfile
from pathlib import Path
from types import SimpleNamespace
from typing import cast
import pytest
from dify_agent.agent_stub.cli import _config as config_cli
from dify_agent.agent_stub.cli._env import AgentStubEnvironment
from dify_agent.agent_stub.client._errors import AgentStubValidationError
from dify_agent.agent_stub.protocol.agent_stub import AgentStubConfigManifestResponse, AgentStubConfigPushRequest
def _manifest_payload() -> AgentStubConfigManifestResponse:
return AgentStubConfigManifestResponse.model_validate(
{
"agent_id": "agent-1",
"config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True},
"skills": {"items": [{"name": "alpha", "description": "Alpha skill"}]},
"files": {"items": [{"name": "guide.txt"}]},
"env_keys": ["API_KEY", "JSON_VALUE"],
"note": "Use carefully.",
}
)
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()
def _environment() -> AgentStubEnvironment:
return AgentStubEnvironment(url="https://agent.example.com/agent-stub", auth_jwe="test-jwe")
def test_pull_config_skills_from_environment_downloads_and_extracts_archives(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
monkeypatch.setattr(config_cli, "request_agent_stub_config_manifest_sync", lambda **_kwargs: _manifest_payload())
monkeypatch.setattr(
config_cli,
"request_agent_stub_config_skill_pull_sync",
lambda **_kwargs: _zip_bytes({"SKILL.md": b"# Alpha\n", "refs/spec.md": b"spec"}),
)
result = config_cli.pull_config_skills_from_environment(local_dir=str(tmp_path))
assert [item.name for item in result.items] == ["alpha"]
assert Path(result.items[0].archive_path).read_bytes()
assert Path(result.items[0].directory_path, "SKILL.md").read_text(encoding="utf-8") == "# Alpha\n"
assert result.items[0].skill_md == "# Alpha\n"
def test_pull_config_files_from_environment_downloads_visible_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
monkeypatch.setattr(config_cli, "request_agent_stub_config_manifest_sync", lambda **_kwargs: _manifest_payload())
monkeypatch.setattr(
config_cli,
"request_agent_stub_config_file_pull_sync",
lambda **_kwargs: b"guide-bytes",
)
result = config_cli.pull_config_files_from_environment(local_dir=str(tmp_path))
assert result.items == [config_cli.ConfigFilePullResult.Item(name="guide.txt", path=str(tmp_path / "guide.txt"))]
assert (tmp_path / "guide.txt").read_bytes() == b"guide-bytes"
def test_pull_config_env_from_environment_writes_only_declared_keys(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(config_cli, "manifest_from_environment", _manifest_payload)
monkeypatch.setenv("API_KEY", "plain")
monkeypatch.setenv("JSON_VALUE", "two words")
result = config_cli.pull_config_env_from_environment(local_path=str(tmp_path / ".env"))
assert result.read_text(encoding="utf-8") == 'API_KEY=plain\nJSON_VALUE="two words"\n'
def test_pull_config_env_and_note_use_hidden_default_dir(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(config_cli, "manifest_from_environment", _manifest_payload)
monkeypatch.setenv("API_KEY", "plain")
monkeypatch.setenv("JSON_VALUE", "two words")
env_path = config_cli.pull_config_env_from_environment()
note_path = config_cli.pull_config_note_from_environment()
assert env_path == (tmp_path / ".dify_conf" / ".env").resolve()
assert note_path == (tmp_path / ".dify_conf" / "note.md").resolve()
assert env_path.read_text(encoding="utf-8") == 'API_KEY=plain\nJSON_VALUE="two words"\n'
assert note_path.read_text(encoding="utf-8") == "Use carefully."
def test_push_config_note_from_environment_reads_default_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
note_path = tmp_path / ".dify_conf" / "note.md"
note_path.parent.mkdir()
note_path.write_text("hello", encoding="utf-8")
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
response = config_cli.push_config_note_from_environment(None)
assert response.agent_id == "agent-1"
request = cast(AgentStubConfigPushRequest, captured["request"])
assert request.note == "hello"
assert request.env_text is None
assert request.files == []
assert request.skills == []
def test_push_config_note_from_environment_reads_explicit_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
note_path = tmp_path / "note.md"
note_path.write_text("explicit", encoding="utf-8")
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.push_config_note_from_environment(str(note_path))
request = cast(AgentStubConfigPushRequest, captured["request"])
assert request.note == "explicit"
def test_push_config_note_from_environment_reads_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
monkeypatch.setattr(config_cli.os, "dup", lambda _fd: 99)
monkeypatch.setattr(config_cli.os, "fdopen", lambda _fd, encoding: io.StringIO("from-stdin"))
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.push_config_note_from_environment("-")
request = cast(AgentStubConfigPushRequest, captured["request"])
assert request.note == "from-stdin"
def test_push_config_env_from_environment_reads_default_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
env_path = tmp_path / ".dify_conf" / ".env"
env_path.parent.mkdir()
env_path.write_text("API_KEY=value\n", encoding="utf-8")
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.push_config_env_from_environment(None)
request = cast(AgentStubConfigPushRequest, captured["request"])
assert request.env_text == "API_KEY=value\n"
assert request.note is None
def test_push_config_env_from_environment_reads_explicit_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
env_path = tmp_path / "custom.env"
env_path.write_text("API_KEY=custom\n", encoding="utf-8")
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.push_config_env_from_environment(str(env_path))
request = cast(AgentStubConfigPushRequest, captured["request"])
assert request.env_text == "API_KEY=custom\n"
def test_push_config_env_from_environment_reads_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
monkeypatch.setattr(config_cli.os, "dup", lambda _fd: 99)
monkeypatch.setattr(config_cli.os, "fdopen", lambda _fd, encoding: io.StringIO("API_KEY=stdin\n"))
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.push_config_env_from_environment("-")
request = cast(AgentStubConfigPushRequest, captured["request"])
assert request.env_text == "API_KEY=stdin\n"
def test_push_config_files_from_environment_builds_upload_items(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
file_path = tmp_path / "guide.txt"
file_path.write_text("guide", encoding="utf-8")
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
monkeypatch.setattr(
config_cli,
"upload_tool_file_resource_from_environment",
lambda *, path: SimpleNamespace(tool_file_id=f"tool-file:{Path(path).name}"),
)
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.push_config_files_from_environment([str(file_path)], None)
request = cast(AgentStubConfigPushRequest, captured["request"])
assert request.files[0].name == "guide.txt"
assert request.files[0].file_ref is not None
assert request.files[0].file_ref.id == "tool-file:guide.txt"
assert request.skills == []
def test_push_config_files_from_environment_validates_name_usage(tmp_path: Path) -> None:
first = tmp_path / "a.txt"
second = tmp_path / "b.txt"
first.write_text("a", encoding="utf-8")
second.write_text("b", encoding="utf-8")
with pytest.raises(AgentStubValidationError, match="--name requires exactly one PATH"):
config_cli.push_config_files_from_environment([str(first), str(second)], "renamed.txt")
def test_push_config_files_from_environment_rejects_empty_paths() -> None:
with pytest.raises(AgentStubValidationError, match="at least one file path is required"):
config_cli.push_config_files_from_environment([], None)
def test_delete_config_files_from_environment_builds_delete_items(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.delete_config_files_from_environment(["old.txt", "legacy.txt"])
request = cast(AgentStubConfigPushRequest, captured["request"])
assert [item.name for item in request.files] == ["old.txt", "legacy.txt"]
assert all(item.file_ref is None for item in request.files)
def test_delete_config_files_from_environment_rejects_empty_names() -> None:
with pytest.raises(AgentStubValidationError, match="at least one file name is required"):
config_cli.delete_config_files_from_environment([])
def test_push_config_skills_from_environment_builds_archive_upload_items(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
skill_dir = tmp_path / "alpha"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Alpha\n", encoding="utf-8")
uploaded_paths: list[str] = []
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
def fake_upload_tool_file_resource_from_environment(*, path: str):
uploaded_paths.append(path)
return SimpleNamespace(tool_file_id=f"tool-file-{len(uploaded_paths)}")
monkeypatch.setattr(
config_cli,
"upload_tool_file_resource_from_environment",
fake_upload_tool_file_resource_from_environment,
)
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.push_config_skills_from_environment([str(skill_dir)])
request = cast(AgentStubConfigPushRequest, captured["request"])
assert request.skills[0].name == "alpha"
assert request.skills[0].file_ref is not None
assert request.skills[0].file_ref.id == "tool-file-1"
assert len(uploaded_paths) == 1
assert uploaded_paths[0].endswith("/alpha.zip")
def test_push_config_skills_from_environment_rejects_empty_paths() -> None:
with pytest.raises(AgentStubValidationError, match="at least one skill directory is required"):
config_cli.push_config_skills_from_environment([])
def test_delete_config_skills_from_environment_builds_delete_items(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.delete_config_skills_from_environment(["alpha", "beta"])
request = cast(AgentStubConfigPushRequest, captured["request"])
assert [item.name for item in request.skills] == ["alpha", "beta"]
assert all(item.file_ref is None for item in request.skills)
def test_delete_config_skills_from_environment_rejects_empty_names() -> None:
with pytest.raises(AgentStubValidationError, match="at least one skill name is required"):
config_cli.delete_config_skills_from_environment([])
def test_build_file_push_item_rejects_non_regular_files(tmp_path: Path) -> None:
with pytest.raises(AgentStubValidationError, match="regular file"):
config_cli._build_file_push_item(item=config_cli._PreparedPushItem(name="bad", path=tmp_path))
def test_build_skill_push_item_rejects_missing_skill_md(tmp_path: Path) -> None:
skill_dir = tmp_path / "alpha"
skill_dir.mkdir()
with pytest.raises(AgentStubValidationError, match="must contain SKILL.md"):
config_cli._build_skill_push_item(item=config_cli._PreparedPushItem(name="alpha", path=skill_dir))
@@ -9,6 +9,12 @@ import pytest
from dify_agent.agent_stub.cli._drive import DrivePullResult
from dify_agent.agent_stub.cli.main import main
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConfigFileItemsResponse,
AgentStubConfigFileItem,
AgentStubConfigManifestResponse,
AgentStubConfigSkillItemsResponse,
AgentStubConfigSkillItem,
AgentStubConfigVersionInfo,
AgentStubDriveCommitResponse,
AgentStubDriveItem,
AgentStubDriveManifestResponse,
@@ -21,6 +27,17 @@ def _reference(record_id: str) -> str:
return f"dify-file-ref:{payload}"
def _config_manifest_response() -> AgentStubConfigManifestResponse:
return AgentStubConfigManifestResponse(
agent_id="agent-1",
config_version=AgentStubConfigVersionInfo(id="cfg-1", kind="build_draft", writable=True),
skills=AgentStubConfigSkillItemsResponse(items=[]),
files=AgentStubConfigFileItemsResponse(items=[]),
env_keys=[],
note="Runtime note.",
)
def test_cli_connect_reports_missing_environment_variables(capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["connect"])
@@ -91,6 +108,63 @@ def test_cli_connect_help_routes_to_typer_help(capsys: pytest.CaptureFixture[str
assert exc_info.value.code == 0
assert "Establish one Agent Stub connection" in captured.out
assert "--json" in captured.out
assert "" not in captured.out
assert "" not in captured.out
def test_cli_config_push_is_not_a_valid_command(capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["config", "push"])
captured = capsys.readouterr()
assert exc_info.value.code == 2
assert "No such command 'push'" in captured.err
assert "" not in captured.err
assert "" not in captured.err
def test_cli_config_help_lists_plural_groups_and_no_root_push(capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["config", "--help"])
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert "Usage: dify-agent config" in captured.out
assert "manifest" in captured.out
assert "files" in captured.out
assert "skills" in captured.out
assert "env" in captured.out
assert "note" in captured.out
@pytest.mark.parametrize("argv", [["config", "files", "--help"], ["config", "skills", "--help"]])
def test_cli_plural_config_groups_expose_pull_push_and_delete(
capsys: pytest.CaptureFixture[str],
argv: list[str],
) -> None:
with pytest.raises(SystemExit) as exc_info:
main(argv)
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert "pull" in captured.out
assert "push" in captured.out
assert "delete" in captured.out
@pytest.mark.parametrize("argv", [["config", "file", "--help"], ["config", "skill", "--help"]])
def test_cli_hidden_singular_alias_help_exposes_pull_only(
capsys: pytest.CaptureFixture[str],
argv: list[str],
) -> None:
with pytest.raises(SystemExit) as exc_info:
main(argv)
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert "pull" in captured.out
assert "push" not in captured.out
assert "delete" not in captured.out
def test_cli_reports_invalid_agent_stub_api_base_url_environment_value(
@@ -154,6 +228,205 @@ def test_cli_connect_accepts_grpc_agent_stub_api_base_url(
assert captured.out.strip() == "connected conn-1"
def test_cli_config_manifest_omits_hash_fields(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.manifest_from_environment",
lambda: AgentStubConfigManifestResponse(
agent_id="agent-1",
config_version=AgentStubConfigVersionInfo(id="cfg-1", kind="build_draft", writable=True),
skills=AgentStubConfigSkillItemsResponse(
items=[
AgentStubConfigSkillItem(
name="alpha",
description="Alpha skill.",
size=12,
hash="sha256:skill",
mime_type="application/zip",
)
]
),
files=AgentStubConfigFileItemsResponse(
items=[
AgentStubConfigFileItem(
name="guide.txt",
size=34,
hash="sha256:file",
mime_type="text/plain",
)
]
),
env_keys=["RUNTIME_KEY"],
note="Runtime note.",
),
)
with pytest.raises(SystemExit) as exc_info:
main(["config", "manifest"])
captured = capsys.readouterr()
payload = json.loads(captured.out)
assert exc_info.value.code == 0
assert payload["skills"] == {
"items": [
{
"name": "alpha",
"description": "Alpha skill.",
"size": 12,
"mime_type": "application/zip",
}
]
}
assert payload["files"] == {
"items": [
{
"name": "guide.txt",
"size": 34,
"mime_type": "text/plain",
}
]
}
@pytest.mark.parametrize(
("argv", "helper_name", "expected_kwargs"),
[
(["config", "note", "push"], "push_config_note_from_environment", {"local_path": None}),
(
["config", "note", "push", "/tmp/note.md"],
"push_config_note_from_environment",
{"local_path": "/tmp/note.md"},
),
(["config", "env", "push"], "push_config_env_from_environment", {"local_path": None}),
(["config", "env", "push", "/tmp/.env"], "push_config_env_from_environment", {"local_path": "/tmp/.env"}),
(
["config", "files", "push", "/tmp/guide.txt", "--name", "runtime.txt"],
"push_config_files_from_environment",
{"paths": ["/tmp/guide.txt"], "name": "runtime.txt"},
),
(
["config", "files", "delete", "old.txt", "legacy.txt"],
"delete_config_files_from_environment",
{"names": ["old.txt", "legacy.txt"]},
),
(
["config", "skills", "push", "/tmp/alpha", "/tmp/beta"],
"push_config_skills_from_environment",
{"paths": ["/tmp/alpha", "/tmp/beta"]},
),
(
["config", "skills", "delete", "alpha", "beta"],
"delete_config_skills_from_environment",
{"names": ["alpha", "beta"]},
),
],
)
def test_cli_config_mutation_commands_forward_and_print_manifest_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
argv: list[str],
helper_name: str,
expected_kwargs: dict[str, object],
) -> None:
captured_kwargs: dict[str, object] = {}
def fake_helper(**kwargs):
captured_kwargs.update(kwargs)
return _config_manifest_response()
monkeypatch.setattr(f"dify_agent.agent_stub.cli.main.{helper_name}", fake_helper)
with pytest.raises(SystemExit) as exc_info:
main(argv)
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert json.loads(captured.out) == json.loads(_config_manifest_response().model_dump_json())
assert captured_kwargs == expected_kwargs
@pytest.mark.parametrize(
("argv", "helper_name"),
[
(["config", "skills", "pull", "alpha", "--json"], "pull_config_skills_from_environment"),
(["config", "skill", "pull", "alpha", "--json"], "pull_config_skills_from_environment"),
(["config", "files", "pull", "guide.txt", "--json"], "pull_config_files_from_environment"),
(["config", "file", "pull", "guide.txt", "--json"], "pull_config_files_from_environment"),
],
)
def test_cli_config_pull_commands_support_plural_and_hidden_singular_aliases(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
argv: list[str],
helper_name: str,
) -> None:
if "skills" in helper_name:
expected_json = {
"items": [
{
"name": "alpha",
"archive_path": "/tmp/alpha.zip",
"directory_path": "/tmp/alpha",
"skill_md": "# Alpha\n",
}
]
}
response = type(
"Response",
(),
{"model_dump_json": lambda self: json.dumps(expected_json)},
)()
expected_kwargs = {"names": ["alpha"], "local_dir": None}
else:
expected_json = {"items": [{"name": "guide.txt", "path": "/tmp/guide.txt"}]}
response = type(
"Response",
(),
{"model_dump_json": lambda self: json.dumps(expected_json)},
)()
expected_kwargs = {"names": ["guide.txt"], "local_dir": None}
captured_kwargs: dict[str, object] = {}
def fake_helper(*, names, local_dir):
captured_kwargs["names"] = names
captured_kwargs["local_dir"] = local_dir
return response
monkeypatch.setattr(f"dify_agent.agent_stub.cli.main.{helper_name}", fake_helper)
with pytest.raises(SystemExit) as exc_info:
main(argv)
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert json.loads(captured.out) == expected_json
assert captured_kwargs == expected_kwargs
@pytest.mark.parametrize(
"argv",
[
["config", "skill", "push", "/tmp/alpha"],
["config", "skill", "delete", "alpha"],
["config", "file", "push", "/tmp/guide.txt"],
["config", "file", "delete", "guide.txt"],
],
)
def test_cli_hidden_singular_aliases_do_not_expose_mutation_commands(
capsys: pytest.CaptureFixture[str],
argv: list[str],
) -> None:
with pytest.raises(SystemExit) as exc_info:
main(argv)
captured = capsys.readouterr()
assert exc_info.value.code == 2
assert "No such command" in captured.err
def test_cli_file_upload_prints_uploaded_tool_file_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
@@ -0,0 +1,310 @@
from __future__ import annotations
import json
import httpx
import pytest
from dify_agent.agent_stub.client._agent_stub import (
request_agent_stub_config_env_update_sync,
request_agent_stub_config_file_pull_sync,
request_agent_stub_config_manifest_sync,
request_agent_stub_config_note_update_sync,
request_agent_stub_config_push_sync,
request_agent_stub_config_skill_inspect_sync,
request_agent_stub_config_skill_pull_sync,
)
from dify_agent.agent_stub.client._agent_stub_http import (
request_agent_stub_config_env_update_http_sync,
request_agent_stub_config_file_pull_http_sync,
request_agent_stub_config_manifest_http_sync,
request_agent_stub_config_note_update_http_sync,
request_agent_stub_config_push_http_sync,
request_agent_stub_config_skill_inspect_http_sync,
request_agent_stub_config_skill_pull_http_sync,
)
from dify_agent.agent_stub.client._errors import AgentStubClientError, AgentStubHTTPError, AgentStubValidationError
from dify_agent.agent_stub.protocol.agent_stub import AgentStubConfigPushRequest
def _manifest_payload() -> dict[str, object]:
return {
"agent_id": "agent-1",
"config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True},
"skills": {"items": [{"name": "alpha", "description": "Alpha skill"}]},
"files": {"items": [{"name": "guide.txt"}]},
"env_keys": ["API_KEY"],
"note": "Use carefully.",
}
def test_config_manifest_sync_normalizes_url_and_uses_http_transport() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert str(request.url) == "https://agent.example.com/agent-stub/config/manifest"
assert request.headers["Authorization"] == "Bearer test-jwe"
return httpx.Response(200, json=_manifest_payload())
http_client = httpx.Client(transport=httpx.MockTransport(handler))
try:
response = request_agent_stub_config_manifest_sync(
url="https://agent.example.com/agent-stub/",
auth_jwe="test-jwe",
sync_http_client=http_client,
)
finally:
http_client.close()
assert response.agent_id == "agent-1"
@pytest.mark.parametrize(
("func", "kwargs"),
[
(request_agent_stub_config_manifest_sync, {}),
(request_agent_stub_config_skill_pull_sync, {"name": "alpha"}),
(request_agent_stub_config_skill_inspect_sync, {"name": "alpha"}),
(request_agent_stub_config_file_pull_sync, {"name": "guide.txt"}),
(request_agent_stub_config_push_sync, {"request": AgentStubConfigPushRequest(note="hello")}),
(request_agent_stub_config_env_update_sync, {"env_text": "API_KEY=value\n"}),
(request_agent_stub_config_note_update_sync, {"note": "hello"}),
],
)
def test_config_sync_entrypoints_reject_grpc_urls(func, kwargs: dict[str, object]) -> None:
with pytest.raises(AgentStubValidationError, match="require an HTTP Agent Stub URL"):
func(
url="grpc://agent.example.com:9091",
auth_jwe="test-jwe",
**kwargs,
)
def test_request_agent_stub_config_manifest_http_sync_gets_manifest() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert str(request.url) == "https://agent.example.com/agent-stub/config/manifest"
assert request.headers["Authorization"] == "Bearer test-jwe"
return httpx.Response(200, json=_manifest_payload())
http_client = httpx.Client(transport=httpx.MockTransport(handler))
try:
response = request_agent_stub_config_manifest_http_sync(
base_url="https://agent.example.com/agent-stub",
auth_jwe="test-jwe",
sync_http_client=http_client,
)
finally:
http_client.close()
assert response.agent_id == "agent-1"
assert response.config_version.kind == "build_draft"
def test_request_agent_stub_config_skill_pull_http_sync_returns_binary_payload() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert str(request.url) == "https://agent.example.com/agent-stub/config/skills/alpha/pull"
return httpx.Response(200, content=b"zip-bytes")
http_client = httpx.Client(transport=httpx.MockTransport(handler))
try:
payload = request_agent_stub_config_skill_pull_http_sync(
base_url="https://agent.example.com/agent-stub",
auth_jwe="test-jwe",
name="alpha",
sync_http_client=http_client,
)
finally:
http_client.close()
assert payload == b"zip-bytes"
def test_request_agent_stub_config_skill_inspect_http_sync_returns_json_dict() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert str(request.url) == "https://agent.example.com/agent-stub/config/skills/alpha/inspect"
return httpx.Response(200, json={"name": "alpha", "files": ["SKILL.md"]})
http_client = httpx.Client(transport=httpx.MockTransport(handler))
try:
payload = request_agent_stub_config_skill_inspect_http_sync(
base_url="https://agent.example.com/agent-stub",
auth_jwe="test-jwe",
name="alpha",
sync_http_client=http_client,
)
finally:
http_client.close()
assert payload == {"name": "alpha", "files": ["SKILL.md"]}
def test_request_agent_stub_config_file_pull_http_sync_returns_binary_payload() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert str(request.url) == "https://agent.example.com/agent-stub/config/files/guide.txt/pull"
return httpx.Response(200, content=b"file-bytes")
http_client = httpx.Client(transport=httpx.MockTransport(handler))
try:
payload = request_agent_stub_config_file_pull_http_sync(
base_url="https://agent.example.com/agent-stub",
auth_jwe="test-jwe",
name="guide.txt",
sync_http_client=http_client,
)
finally:
http_client.close()
assert payload == b"file-bytes"
def test_request_agent_stub_config_push_http_sync_posts_json_and_validates_response() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert str(request.url) == "https://agent.example.com/agent-stub/config/push"
assert json.loads(request.content) == {"note": "hello"}
return httpx.Response(200, json=_manifest_payload())
http_client = httpx.Client(transport=httpx.MockTransport(handler))
try:
response = request_agent_stub_config_push_http_sync(
base_url="https://agent.example.com/agent-stub",
auth_jwe="test-jwe",
request=AgentStubConfigPushRequest(note="hello"),
sync_http_client=http_client,
)
finally:
http_client.close()
assert response.note == "Use carefully."
@pytest.mark.parametrize(
("func", "kwargs", "expected_path", "expected_body", "expected_response"),
[
(
request_agent_stub_config_env_update_http_sync,
{"env_text": "API_KEY=value\n"},
"https://agent.example.com/agent-stub/config/env",
{"env_text": "API_KEY=value\n"},
{"env_keys": ["API_KEY"]},
),
(
request_agent_stub_config_note_update_http_sync,
{"note": "hello"},
"https://agent.example.com/agent-stub/config/note",
{"note": "hello"},
{"note": "hello"},
),
],
)
def test_config_update_http_entrypoints_round_trip_json(
func,
kwargs: dict[str, object],
expected_path: str,
expected_body: dict[str, object],
expected_response: dict[str, object],
) -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert str(request.url) == expected_path
assert json.loads(request.content) == expected_body
return httpx.Response(200, json=expected_response)
http_client = httpx.Client(transport=httpx.MockTransport(handler))
try:
payload = func(
base_url="https://agent.example.com/agent-stub",
auth_jwe="test-jwe",
sync_http_client=http_client,
**kwargs,
)
finally:
http_client.close()
assert payload == expected_response
@pytest.mark.parametrize(
("func", "kwargs", "response", "expected_error", "expected_match"),
[
(
request_agent_stub_config_manifest_http_sync,
{},
httpx.Response(401, json={"detail": "denied"}),
AgentStubHTTPError,
"401",
),
(
request_agent_stub_config_manifest_http_sync,
{},
httpx.Response(200, text="not-json", headers={"Content-Type": "application/json"}),
AgentStubClientError,
"invalid JSON",
),
(
request_agent_stub_config_push_http_sync,
{"request": AgentStubConfigPushRequest(note="hello")},
httpx.Response(200, json={"bad": "shape"}),
AgentStubValidationError,
"config push response",
),
(
request_agent_stub_config_skill_pull_http_sync,
{"name": "alpha"},
httpx.Response(404, json={"detail": "missing"}),
AgentStubHTTPError,
"404",
),
(
request_agent_stub_config_skill_inspect_http_sync,
{"name": "alpha"},
httpx.Response(200, json=["bad"]),
AgentStubValidationError,
"config skill inspect response",
),
(
request_agent_stub_config_file_pull_http_sync,
{"name": "guide.txt"},
httpx.Response(404, json={"detail": "missing"}),
AgentStubHTTPError,
"404",
),
(
request_agent_stub_config_env_update_http_sync,
{"env_text": "API_KEY=value\n"},
httpx.Response(200, json=["bad"]),
AgentStubValidationError,
"config env update response",
),
(
request_agent_stub_config_note_update_http_sync,
{"note": "hello"},
httpx.Response(200, text="not-json", headers={"Content-Type": "application/json"}),
AgentStubClientError,
"invalid JSON",
),
],
)
def test_config_http_entrypoints_map_http_json_and_validation_errors(
func,
kwargs: dict[str, object],
response: httpx.Response,
expected_error: type[Exception],
expected_match: str,
) -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return response
http_client = httpx.Client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(expected_error, match=expected_match):
func(
base_url="https://agent.example.com/agent-stub",
auth_jwe="test-jwe",
sync_http_client=http_client,
**kwargs,
)
finally:
http_client.close()
@@ -0,0 +1,516 @@
from __future__ import annotations
import base64
import json
import secrets
import time
from typing import cast
import httpx
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConfigManifestResponse,
AgentStubConfigPushRequest,
AgentStubConfigPushResponse,
)
from dify_agent.agent_stub.server.agent_stub_config import (
AgentStubConfigRequestError,
AgentStubConfigRequestHandler,
DifyApiAgentStubConfigRequestHandler,
)
from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneError, AgentStubControlPlaneService
from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal, AgentStubTokenCodec
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
def _base64url_secret(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _token_codec() -> AgentStubTokenCodec:
return AgentStubTokenCodec.from_server_secret(_base64url_secret(secrets.token_bytes(32)))
def _execution_context(**updates: object) -> DifyExecutionContextLayerConfig:
payload = {
"tenant_id": "tenant-1",
"user_id": "user-1",
"user_from": "account",
"agent_mode": "workflow_run",
"invoke_from": "service-api",
"agent_id": "agent-1",
"agent_config_version_id": "cfg-1",
"agent_config_version_kind": "build_draft",
}
payload.update(updates)
return DifyExecutionContextLayerConfig.model_validate(payload)
def _principal(**context_updates: object) -> AgentStubPrincipal:
return AgentStubPrincipal(
execution_context=_execution_context(**context_updates),
session_id=None,
scope=["agent_stub:connect"],
token_id="token-1",
)
def _manifest_payload() -> dict[str, object]:
return {
"agent_id": "agent-1",
"config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True, "source": "inner-api"},
"skills": {"items": [{"id": "alpha", "name": "alpha", "description": "Alpha skill", "file_id": "tool-file-1"}]},
"files": {"items": [{"id": "guide.txt", "name": "guide.txt", "file_id": "upload-file-1"}]},
"env_keys": ["API_KEY"],
"note": "Use carefully.",
"ignored": True,
}
@pytest.mark.anyio
async def test_dify_api_handler_manifest_success(monkeypatch: pytest.MonkeyPatch) -> None:
original_async_client = httpx.AsyncClient
def handler(request: httpx.Request) -> httpx.Response:
assert str(request.url) == (
"https://api.example.com/inner/api/agent-config/agent-1/manifest"
"?tenant_id=tenant-1&config_version_id=cfg-1&config_version_kind=build_draft&user_id=user-1"
)
assert request.headers["X-Inner-Api-Key"] == "inner-secret"
return httpx.Response(200, json=_manifest_payload())
monkeypatch.setattr(
"dify_agent.agent_stub.server.agent_stub_config.httpx.AsyncClient",
lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
)
response = await DifyApiAgentStubConfigRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
).manifest(principal=_principal())
assert isinstance(response, AgentStubConfigManifestResponse)
assert response.agent_id == "agent-1"
assert response.config_version.kind == "build_draft"
assert response.skills.items[0].model_dump() == {
"name": "alpha",
"description": "Alpha skill",
"size": None,
"hash": None,
"mime_type": None,
}
assert response.files.items[0].model_dump() == {
"name": "guide.txt",
"size": None,
"hash": None,
"mime_type": None,
}
@pytest.mark.anyio
async def test_dify_api_handler_pull_endpoints_return_bytes(monkeypatch: pytest.MonkeyPatch) -> None:
original_async_client = httpx.AsyncClient
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.params["user_id"] == "user-1"
if request.url.path.endswith("/skills/alpha/pull"):
return httpx.Response(200, content=b"zip-bytes")
if request.url.path.endswith("/files/guide.txt/pull"):
return httpx.Response(200, content=b"file-bytes")
raise AssertionError(f"unexpected path: {request.url.path}")
monkeypatch.setattr(
"dify_agent.agent_stub.server.agent_stub_config.httpx.AsyncClient",
lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
)
request_handler = DifyApiAgentStubConfigRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
assert await request_handler.pull_skill(principal=_principal(), name="alpha") == b"zip-bytes"
assert await request_handler.pull_file(principal=_principal(), name="guide.txt") == b"file-bytes"
@pytest.mark.anyio
async def test_dify_api_handler_push_env_and_note_success(monkeypatch: pytest.MonkeyPatch) -> None:
original_async_client = httpx.AsyncClient
captured: list[tuple[str, dict[str, object]]] = []
def handler(request: httpx.Request) -> httpx.Response:
payload = json.loads(request.content)
captured.append((request.url.path, payload))
if request.url.path.endswith("/push"):
return httpx.Response(200, json=_manifest_payload())
if request.url.path.endswith("/env"):
return httpx.Response(200, json={"env_keys": ["API_KEY"]})
if request.url.path.endswith("/note"):
return httpx.Response(200, json={"note": "updated"})
raise AssertionError(f"unexpected path: {request.url.path}")
monkeypatch.setattr(
"dify_agent.agent_stub.server.agent_stub_config.httpx.AsyncClient",
lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
)
request_handler = DifyApiAgentStubConfigRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
push_response = await request_handler.push(
principal=_principal(),
request=AgentStubConfigPushRequest(note="hello"),
)
env_response = await request_handler.update_env(principal=_principal(), env_text="API_KEY=value\n")
note_response = await request_handler.update_note(principal=_principal(), note="updated")
assert isinstance(push_response, AgentStubConfigPushResponse)
assert env_response == {"env_keys": ["API_KEY"]}
assert note_response == {"note": "updated"}
assert captured == [
(
"/inner/api/agent-config/agent-1/push",
{
"tenant_id": "tenant-1",
"user_id": "user-1",
"config_version_id": "cfg-1",
"config_version_kind": "build_draft",
"files": [],
"skills": [],
"note": "hello",
},
),
(
"/inner/api/agent-config/agent-1/env",
{
"tenant_id": "tenant-1",
"user_id": "user-1",
"config_version_id": "cfg-1",
"config_version_kind": "build_draft",
"env_text": "API_KEY=value\n",
},
),
(
"/inner/api/agent-config/agent-1/note",
{
"tenant_id": "tenant-1",
"user_id": "user-1",
"config_version_id": "cfg-1",
"config_version_kind": "build_draft",
"note": "updated",
},
),
]
@pytest.mark.anyio
@pytest.mark.parametrize(
("method_name", "path", "response", "expected_status", "expected_message"),
[
(
"manifest",
"/inner/api/agent-config/agent-1/manifest",
httpx.Response(200, json={"bad": "shape"}),
502,
"manifest",
),
(
"pull_skill",
"/inner/api/agent-config/agent-1/skills/alpha/pull",
httpx.Response(404, json={"detail": "missing"}),
404,
"missing",
),
("push", "/inner/api/agent-config/agent-1/push", httpx.Response(200, json={"bad": "shape"}), 502, "push"),
("update_env", "/inner/api/agent-config/agent-1/env", httpx.Response(200, json=["bad"]), 502, "env"),
(
"update_note",
"/inner/api/agent-config/agent-1/note",
httpx.Response(200, text="not-json"),
502,
"invalid JSON",
),
],
)
async def test_dify_api_handler_maps_error_cases(
monkeypatch: pytest.MonkeyPatch,
method_name: str,
path: str,
response: httpx.Response,
expected_status: int,
expected_message: str,
) -> None:
original_async_client = httpx.AsyncClient
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == path
return response
monkeypatch.setattr(
"dify_agent.agent_stub.server.agent_stub_config.httpx.AsyncClient",
lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
)
request_handler = DifyApiAgentStubConfigRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
with pytest.raises(AgentStubConfigRequestError, match=expected_message) as exc_info:
match method_name:
case "manifest":
await request_handler.manifest(principal=_principal())
case "pull_skill":
await request_handler.pull_skill(principal=_principal(), name="alpha")
case "push":
await request_handler.push(principal=_principal(), request=AgentStubConfigPushRequest())
case "update_env":
await request_handler.update_env(principal=_principal(), env_text="API_KEY=value\n")
case "update_note":
await request_handler.update_note(principal=_principal(), note="hello")
case _:
raise AssertionError(f"unexpected method: {method_name}")
assert exc_info.value.status_code == expected_status
@pytest.mark.anyio
@pytest.mark.parametrize(
("context_updates", "expected_message"),
[
({"agent_id": None}, "agent_id is required"),
({"agent_config_version_id": None}, "agent_config_version_id is required"),
({"agent_config_version_kind": None}, "agent_config_version_kind is required"),
({"user_id": None}, "user_id is required"),
],
)
async def test_dify_api_handler_validates_required_execution_context_fields(
context_updates: dict[str, object],
expected_message: str,
) -> None:
request_handler = DifyApiAgentStubConfigRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
with pytest.raises(AgentStubConfigRequestError, match=expected_message) as exc_info:
if context_updates.get("user_id", "user-1") is None:
await request_handler.push(principal=_principal(**context_updates), request=AgentStubConfigPushRequest())
else:
await request_handler.manifest(principal=_principal(**context_updates))
assert exc_info.value.status_code == 400
@pytest.mark.anyio
@pytest.mark.parametrize(
"method_name",
[
"get_config_manifest",
"pull_config_skill",
"inspect_config_skill",
"pull_config_file",
"push_config",
"update_config_env",
"update_config_note",
],
)
async def test_control_plane_maps_config_request_errors(method_name: str) -> None:
codec = _token_codec()
authorization = f"Bearer {codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)}"
class FakeHandler:
async def manifest(self, *, principal):
del principal
raise AgentStubConfigRequestError(409, {"code": "conflict"})
async def pull_skill(self, *, principal, name):
del principal, name
raise AgentStubConfigRequestError(409, {"code": "conflict"})
async def inspect_skill(self, *, principal, name):
del principal, name
raise AgentStubConfigRequestError(409, {"code": "conflict"})
async def pull_file(self, *, principal, name):
del principal, name
raise AgentStubConfigRequestError(409, {"code": "conflict"})
async def push(self, *, principal, request):
del principal, request
raise AgentStubConfigRequestError(409, {"code": "conflict"})
async def update_env(self, *, principal, env_text):
del principal, env_text
raise AgentStubConfigRequestError(409, {"code": "conflict"})
async def update_note(self, *, principal, note):
del principal, note
raise AgentStubConfigRequestError(409, {"code": "conflict"})
service = AgentStubControlPlaneService(
codec, config_request_handler=cast(AgentStubConfigRequestHandler, FakeHandler())
)
with pytest.raises(AgentStubControlPlaneError) as exc_info:
match method_name:
case "get_config_manifest":
await service.get_config_manifest(authorization=authorization)
case "pull_config_skill":
await service.pull_config_skill(name="alpha", authorization=authorization)
case "inspect_config_skill":
await service.inspect_config_skill(name="alpha", authorization=authorization)
case "pull_config_file":
await service.pull_config_file(name="guide.txt", authorization=authorization)
case "push_config":
await service.push_config(request=AgentStubConfigPushRequest(), authorization=authorization)
case "update_config_env":
await service.update_config_env(env_text="API_KEY=value\n", authorization=authorization)
case "update_config_note":
await service.update_config_note(note="hello", authorization=authorization)
case _:
raise AssertionError(f"unexpected method: {method_name}")
assert exc_info.value.status_code == 409
assert exc_info.value.detail == {"code": "conflict"}
@pytest.mark.parametrize(
("path", "method", "body", "expected_status", "expected_detail"),
[
("/agent-stub/config/manifest", "get", None, 200, {"agent_id": "agent-1"}),
("/agent-stub/config/skills/alpha/pull", "get", None, 200, b"zip-bytes"),
("/agent-stub/config/skills/alpha/inspect", "get", None, 200, {"name": "alpha", "files": ["SKILL.md"]}),
("/agent-stub/config/files/guide.txt/pull", "get", None, 200, b"file-bytes"),
("/agent-stub/config/push", "post", {"note": "hello"}, 200, {"agent_id": "agent-1"}),
("/agent-stub/config/env", "patch", {"env_text": "API_KEY=value\n"}, 200, {"env_keys": ["API_KEY"]}),
("/agent-stub/config/note", "put", {"note": "hello"}, 200, {"note": "hello"}),
],
)
def test_http_config_routes_forward_requests(
path: str,
method: str,
body: dict[str, object] | None,
expected_status: int,
expected_detail: dict[str, object] | bytes,
) -> None:
codec = _token_codec()
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
captured: dict[str, object] = {}
class FakeHandler:
async def manifest(self, *, principal):
captured["manifest_agent_id"] = principal.execution_context.agent_id
return AgentStubConfigManifestResponse.model_validate(_manifest_payload())
async def pull_skill(self, *, principal, name):
del principal
captured["skill_name"] = name
return b"zip-bytes"
async def inspect_skill(self, *, principal, name):
del principal
captured["inspect_name"] = name
return {"name": name, "files": ["SKILL.md"]}
async def pull_file(self, *, principal, name):
del principal
captured["file_name"] = name
return b"file-bytes"
async def push(self, *, principal, request):
del principal
captured["push_note"] = request.note
return AgentStubConfigPushResponse.model_validate(_manifest_payload())
async def update_env(self, *, principal, env_text):
del principal
captured["env_text"] = env_text
return {"env_keys": ["API_KEY"]}
async def update_note(self, *, principal, note):
del principal
captured["note"] = note
return {"note": note}
app = FastAPI()
app.include_router(
create_agent_stub_http_router(codec, config_request_handler=cast(AgentStubConfigRequestHandler, FakeHandler()))
)
client = TestClient(app)
headers = {"Authorization": f"Bearer {token}"}
response = client.request(method.upper(), path, headers=headers, json=body)
assert response.status_code == expected_status
if isinstance(expected_detail, bytes):
assert response.content == expected_detail
else:
for key, value in expected_detail.items():
assert response.json()[key] == value
if path.endswith("/manifest"):
assert captured["manifest_agent_id"] == "agent-1"
elif path.endswith("/skills/alpha/pull"):
assert captured["skill_name"] == "alpha"
assert response.headers["content-type"] == "application/zip"
elif path.endswith("/skills/alpha/inspect"):
assert captured["inspect_name"] == "alpha"
elif path.endswith("/files/guide.txt/pull"):
assert captured["file_name"] == "guide.txt"
assert response.headers["content-type"] == "application/octet-stream"
elif path.endswith("/config/push"):
assert captured["push_note"] == "hello"
elif path.endswith("/config/env"):
assert captured["env_text"] == "API_KEY=value\n"
elif path.endswith("/config/note"):
assert captured["note"] == "hello"
def test_http_config_routes_map_handler_errors() -> None:
codec = _token_codec()
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
class ErrorHandler:
async def manifest(self, *, principal):
del principal
raise AgentStubConfigRequestError(422, {"code": "invalid_request"})
async def pull_skill(self, *, principal, name):
del principal, name
raise AssertionError("unexpected route")
async def inspect_skill(self, *, principal, name):
del principal, name
raise AssertionError("unexpected route")
async def pull_file(self, *, principal, name):
del principal, name
raise AssertionError("unexpected route")
async def push(self, *, principal, request):
del principal, request
raise AssertionError("unexpected route")
async def update_env(self, *, principal, env_text):
del principal, env_text
raise AssertionError("unexpected route")
async def update_note(self, *, principal, note):
del principal, note
raise AssertionError("unexpected route")
app = FastAPI()
app.include_router(
create_agent_stub_http_router(codec, config_request_handler=cast(AgentStubConfigRequestHandler, ErrorHandler()))
)
client = TestClient(app)
response = client.get("/agent-stub/config/manifest", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 422
assert response.json()["detail"] == {"code": "invalid_request"}
@@ -100,6 +100,7 @@ def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_commit(
"key": "skills/example/SKILL.md",
"file_ref": {"kind": "tool_file", "id": "tool-file-1"},
"value_owned_by_drive": True,
"is_skill": False,
}
],
}
@@ -0,0 +1,202 @@
"""Behavior tests for the runtime Dify config layer."""
from __future__ import annotations
import asyncio
from typing import Literal
import pytest
from dify_agent.adapters.shell.shellctl import ShellctlProvider
from dify_agent.layers.config import DifyConfigLayerConfig
from dify_agent.layers.config.layer import DifyConfigLayer, DifyConfigLayerError
from dify_agent.layers.shell import DifyShellLayerConfig
from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer
def _unused_client_factory():
raise AssertionError("shellctl client should not be used by these config-layer tests")
def _shell_layer() -> DifyShellLayer:
return DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
shell_provider=ShellctlProvider(entrypoint="http://shellctl", token="", client_factory=_unused_client_factory),
)
def _build_layer(*, writable: bool = True) -> DifyConfigLayer:
layer = DifyConfigLayer.from_config(
DifyConfigLayerConfig.model_validate(
{
"agent_id": "agent-1",
"config_version": {"id": "cfg-1", "kind": "build_draft", "writable": writable},
"skills": [{"name": "runtime-skill", "description": "Runtime skill."}],
"files": [{"name": "runtime-file.txt"}],
"env_keys": ["RUNTIME_KEY"],
"note": "Runtime note.",
"mentioned_skill_names": ["alpha"],
"mentioned_file_names": ["guide.txt"],
}
)
)
layer.bind_deps({"shell": _shell_layer()})
return layer
def _remote_result(
output: str,
*,
exit_code: int | None = 0,
output_complete: bool = True,
incomplete_reason: Literal["output_limit", "timeout"] | None = None,
) -> CompleteRemoteCommandResult:
return CompleteRemoteCommandResult(
job_id="remote-config-pull",
status="exited",
done=True,
exit_code=exit_code,
output=output,
output_complete=output_complete,
incomplete_reason=incomplete_reason,
offset=len(output),
output_path="/tmp/config-pull-output.log",
)
def _skill_pull_output(*, include_skill: bool = True) -> str:
if not include_skill:
return ""
return "/workspace/.dify_conf/skills/alpha\n# Alpha\nUse it.\n"
def _file_pull_output(*, include_file: bool = True) -> str:
if not include_file:
return ""
return "/workspace/.dify_conf/files/guide.txt\n"
def test_build_shell_pull_scripts_include_targets() -> None:
layer = _build_layer()
skill_script = layer._build_shell_skill_pull_script("alpha")
file_script = layer._build_shell_file_pull_script("guide.txt")
assert skill_script == "set -eu\ndify-agent config skills pull alpha"
assert "__DIFY_CONFIG_SKILLS_BEGIN__" not in skill_script
assert file_script == "set -eu\ndify-agent config files pull guide.txt"
assert "__DIFY_CONFIG_FILES_BEGIN__" not in file_script
@pytest.mark.anyio
async def test_on_context_create_computes_runtime_fields_and_pulls_mentioned_assets_in_parallel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
layer = _build_layer()
captured_scripts: list[str] = []
active_commands = 0
max_active_commands = 0
async def fake_run_remote_script(self, script: str, *, inject_agent_stub_env: bool = False, timeout: float = 10.0):
nonlocal active_commands, max_active_commands
del self, timeout
assert inject_agent_stub_env is True
assert "--help" not in script
assert "dify-agent config manifest" not in script
captured_scripts.append(script)
active_commands += 1
max_active_commands = max(max_active_commands, active_commands)
await asyncio.sleep(0)
active_commands -= 1
if "skills pull" in script:
return _remote_result(_skill_pull_output())
if "files pull" in script:
return _remote_result(_file_pull_output())
raise AssertionError(f"unexpected script: {script}")
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
await layer.on_context_create()
assert max_active_commands > 1
assert len(captured_scripts) == 2
assert sorted(captured_scripts) == [
"set -eu\ndify-agent config files pull guide.txt",
"set -eu\ndify-agent config skills pull alpha",
]
assert layer.runtime_state.pulled_skill_outputs == {"alpha": "/workspace/.dify_conf/skills/alpha\n# Alpha\nUse it."}
assert layer.runtime_state.pulled_file_outputs == {"guide.txt": "/workspace/.dify_conf/files/guide.txt"}
assert "dify-agent config note push --help" in layer.runtime_state.config_cli_help
assert layer.runtime_state.push_spec_json_schema == ""
@pytest.mark.anyio
async def test_on_context_resume_does_not_recompute_or_pull(monkeypatch: pytest.MonkeyPatch) -> None:
layer = _build_layer()
layer.runtime_state.config_context_json = "cached"
layer.runtime_state.config_cli_help = {"cached": "help"}
async def fail_run_remote_script(self, script: str, *, inject_agent_stub_env: bool = False, timeout: float = 10.0):
del self, script, inject_agent_stub_env, timeout
raise AssertionError("resume must not run config shell commands")
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fail_run_remote_script)
await layer.on_context_resume()
assert layer.runtime_state.config_context_json == "cached"
assert layer.runtime_state.config_cli_help == {"cached": "help"}
@pytest.mark.anyio
async def test_on_context_create_raises_when_shell_output_is_truncated(
monkeypatch: pytest.MonkeyPatch,
) -> None:
layer = _build_layer()
async def fake_run_remote_script(self, script: str, *, inject_agent_stub_env: bool = False, timeout: float = 10.0):
del self, inject_agent_stub_env, timeout
if "skills pull" in script:
return _remote_result(_skill_pull_output(), output_complete=False, incomplete_reason="output_limit")
return _remote_result(_file_pull_output())
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
with pytest.raises(DifyConfigLayerError, match="output was incomplete"):
await layer.on_context_create()
@pytest.mark.anyio
async def test_on_context_create_raises_when_mentioned_skill_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
layer = _build_layer()
async def fake_run_remote_script(self, script: str, *, inject_agent_stub_env: bool = False, timeout: float = 10.0):
del self, inject_agent_stub_env, timeout
if "skills pull" in script:
return _remote_result(_skill_pull_output(include_skill=False))
return _remote_result(_file_pull_output())
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
with pytest.raises(DifyConfigLayerError, match="missing pull output"):
await layer.on_context_create()
@pytest.mark.anyio
async def test_on_context_create_raises_when_mentioned_file_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
layer = _build_layer()
async def fake_run_remote_script(self, script: str, *, inject_agent_stub_env: bool = False, timeout: float = 10.0):
del self, inject_agent_stub_env, timeout
if "skills pull" in script:
return _remote_result(_skill_pull_output())
return _remote_result(_file_pull_output(include_file=False))
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
with pytest.raises(DifyConfigLayerError, match="missing pull output"):
await layer.on_context_create()
@@ -0,0 +1,32 @@
from __future__ import annotations
import sys
import types
from agenton.layers import EmptyLayerConfig, EmptyRuntimeState, NoLayerDeps, PlainLayer
from pydantic import BaseModel
class _BaseSettings(BaseModel):
"""Minimal test stub for environments without pydantic-settings installed."""
class _SettingsConfigDict(dict[str, object]):
"""Minimal callable mapping used by ShellAdapterSettings.model_config."""
if "pydantic_settings" not in sys.modules:
stub = types.ModuleType("pydantic_settings")
setattr(stub, "BaseSettings", _BaseSettings)
setattr(stub, "SettingsConfigDict", _SettingsConfigDict)
sys.modules["pydantic_settings"] = stub
if "dify_agent.layers.execution_context.layer" not in sys.modules:
execution_context_stub = types.ModuleType("dify_agent.layers.execution_context.layer")
class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, EmptyRuntimeState]):
"""Minimal test stub for shell-layer type-only imports."""
setattr(execution_context_stub, "DifyExecutionContextLayer", DifyExecutionContextLayer)
sys.modules["dify_agent.layers.execution_context.layer"] = execution_context_stub
@@ -0,0 +1,165 @@
import json
import httpx
import pytest
from dify_agent.layers.dify_core_tools.client import DifyCoreToolsClient, DifyCoreToolsClientError
from dify_agent.layers.dify_core_tools.configs import DifyCoreToolConfig
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
def _execution_context() -> DifyExecutionContextLayerConfig:
return DifyExecutionContextLayerConfig(
tenant_id="tenant-1",
user_id="user-1",
user_from="account",
app_id="app-1",
workflow_id="workflow-1",
workflow_run_id="workflow-run-1",
node_id="node-1",
node_execution_id="node-exec-1",
conversation_id="conversation-1",
agent_id="agent-1",
agent_config_version_id="snapshot-1",
agent_mode="workflow_run",
invoke_from="service-api",
)
def _tool_config() -> DifyCoreToolConfig:
return DifyCoreToolConfig(
provider_type="builtin",
provider_id="audio",
tool_name="transcribe",
credential_id="credential-1",
runtime_parameters={"language": "en"},
parameters=[],
parameters_json_schema={"type": "object", "properties": {}, "required": []},
)
def test_core_tools_client_posts_inner_api_request() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert str(request.url) == "http://dify-api/inner/api/agent/tools/invoke"
assert request.headers["X-Inner-Api-Key"] == "inner-secret"
payload = json.loads(request.content.decode("utf-8"))
assert payload["caller"]["tenant_id"] == "tenant-1"
assert payload["tool"]["provider_type"] == "builtin"
assert payload["tool"]["runtime_parameters"] == {"language": "en"}
assert payload["tool"]["tool_parameters"] == {"audio_url": "https://example.com/a.mp3"}
return httpx.Response(
200,
json={
"messages": [{"type": "text", "message": {"text": "ok"}}],
"observation": "ok",
"metadata": {"provider_type": "builtin"},
},
)
async def scenario() -> None:
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
client = DifyCoreToolsClient(base_url="http://dify-api", api_key="inner-secret", http_client=http_client)
response = await client.invoke(
execution_context=_execution_context(),
tool_config=_tool_config(),
tool_parameters={"audio_url": "https://example.com/a.mp3"},
)
assert response.observation == "ok"
import asyncio
asyncio.run(scenario())
def test_core_tools_client_marks_retryable_http_failures() -> None:
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(lambda _request: httpx.Response(502, json={"code": "tool_failed"}))
) as http_client:
client = DifyCoreToolsClient(base_url="http://dify-api", api_key="inner-secret", http_client=http_client)
with pytest.raises(DifyCoreToolsClientError) as exc_info:
_ = await client.invoke(
execution_context=_execution_context(),
tool_config=_tool_config(),
tool_parameters={"audio_url": "https://example.com/a.mp3"},
)
assert exc_info.value.retryable is True
import asyncio
asyncio.run(scenario())
@pytest.mark.parametrize(
("error_factory", "expected_substring"),
[
(
lambda request: httpx.ReadTimeout("timed out", request=request),
"timed out",
),
(
lambda request: httpx.ConnectError("connect failed", request=request),
"request failed",
),
],
)
def test_core_tools_client_marks_transport_failures_retryable(error_factory, expected_substring: str) -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise error_factory(request)
async def scenario() -> None:
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
client = DifyCoreToolsClient(base_url="http://dify-api", api_key="inner-secret", http_client=http_client)
with pytest.raises(DifyCoreToolsClientError) as exc_info:
_ = await client.invoke(
execution_context=_execution_context(),
tool_config=_tool_config(),
tool_parameters={"audio_url": "https://example.com/a.mp3"},
)
assert exc_info.value.retryable is True
assert expected_substring in str(exc_info.value)
import asyncio
asyncio.run(scenario())
def test_core_tools_client_validates_execution_context_prerequisites_separately() -> None:
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={}))
) as http_client:
client = DifyCoreToolsClient(base_url="http://dify-api", api_key="inner-secret", http_client=http_client)
with pytest.raises(DifyCoreToolsClientError) as exc_info:
_ = await client.invoke(
execution_context=_execution_context().model_copy(update={"app_id": None}),
tool_config=_tool_config(),
tool_parameters={"audio_url": "https://example.com/a.mp3"},
)
assert exc_info.value.error_code == "missing_execution_context"
assert exc_info.value.retryable is False
assert "app_id" in str(exc_info.value)
import asyncio
asyncio.run(scenario())
def test_core_tools_client_raises_invalid_response_for_malformed_success_body() -> None:
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"messages": []}))
) as http_client:
client = DifyCoreToolsClient(base_url="http://dify-api", api_key="inner-secret", http_client=http_client)
with pytest.raises(DifyCoreToolsClientError) as exc_info:
_ = await client.invoke(
execution_context=_execution_context(),
tool_config=_tool_config(),
tool_parameters={"audio_url": "https://example.com/a.mp3"},
)
assert exc_info.value.error_code == "invalid_response"
assert exc_info.value.retryable is False
import asyncio
asyncio.run(scenario())
@@ -0,0 +1,62 @@
import pytest
from pydantic import ValidationError
import dify_agent.layers.dify_core_tools as dify_core_tools_exports
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
def test_dify_core_tools_package_exports_client_safe_config_symbols_only() -> None:
assert dify_core_tools_exports.__all__ == [
"DIFY_CORE_TOOLS_LAYER_TYPE_ID",
"DifyCoreToolConfig",
"DifyCoreToolProviderType",
"DifyCoreToolsLayerConfig",
]
assert dify_core_tools_exports.DIFY_CORE_TOOLS_LAYER_TYPE_ID == "dify.core.tools"
def test_dify_core_tools_layer_config_accepts_supported_provider_types() -> None:
config = DifyCoreToolsLayerConfig.model_validate(
{
"tools": [
{
"provider_type": "plugin",
"provider_id": "langgenius/search/search",
"tool_name": "search",
},
{
"provider_type": "builtin",
"provider_id": "audio",
"tool_name": "transcribe",
},
{
"provider_type": "api",
"provider_id": "weather",
"tool_name": "forecast",
},
{
"provider_type": "workflow",
"provider_id": "workflow-tool",
"tool_name": "run_workflow",
},
{
"provider_type": "mcp",
"provider_id": "server-1",
"tool_name": "search_docs",
},
]
}
)
assert [tool.provider_type for tool in config.tools] == ["plugin", "builtin", "api", "workflow", "mcp"]
def test_dify_core_tool_config_rejects_unsupported_provider_types() -> None:
with pytest.raises(ValidationError, match="provider_type"):
_ = DifyCoreToolConfig.model_validate(
{
"provider_type": "app",
"provider_id": "app-tool",
"tool_name": "run",
}
)

Some files were not shown because too many files have changed in this diff Show More