mirror of
https://github.com/langgenius/dify.git
synced 2026-07-01 20:44:08 -04:00
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:
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
+236
-52
@@ -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"}
|
||||
+242
-158
@@ -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
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user