mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08: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:
co-authored by
yunlu.wen
autofix-ci[bot]
Yunlu Wen
Copilot Autofix powered by AI
Joel
Yanli 盐粒
盐粒 Yanli
zyssyz123
盐粒 Yanli
parent
f816ae2e95
commit
0923ebaf88
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user