mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
chore(agent-v2): sync changes (#38513)
Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com> Co-authored-by: 盐粒 Yanli <mail@yanli.one> Co-authored-by: Asuka Minato <i@asukaminato.eu.org> Co-authored-by: Jashwanth Reddy Gummula <gmrnlg1971@gmail.com> Co-authored-by: WH-2099 <wh2099@pm.me> Co-authored-by: 非法操作 <hjlarry@163.com> Co-authored-by: wangxiaolei <fatelei@gmail.com> Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com> Co-authored-by: Yansong Zhang <916125788@qq.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
d67123e5fd
commit
98d9b11f7b
@ -18,6 +18,7 @@ from clients.agent_backend.errors import (
|
||||
AgentBackendValidationError,
|
||||
)
|
||||
from clients.agent_backend.event_adapter import (
|
||||
AgentBackendAgentMessageDeltaInternalEvent,
|
||||
AgentBackendDeferredToolCallInternalEvent,
|
||||
AgentBackendInternalEvent,
|
||||
AgentBackendInternalEventType,
|
||||
@ -46,6 +47,11 @@ from clients.agent_backend.request_builder import (
|
||||
AgentBackendWorkflowNodeRunInput,
|
||||
redact_for_agent_backend_log,
|
||||
)
|
||||
from clients.agent_backend.session_cleanup import (
|
||||
AgentBackendSessionCleanupPayload,
|
||||
AgentBackendSessionCleanupResult,
|
||||
cleanup_agent_backend_session,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AGENT_SOUL_PROMPT_LAYER_ID",
|
||||
@ -57,6 +63,7 @@ __all__ = [
|
||||
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
|
||||
"WORKFLOW_USER_PROMPT_LAYER_ID",
|
||||
"AgentBackendAgentAppRunInput",
|
||||
"AgentBackendAgentMessageDeltaInternalEvent",
|
||||
"AgentBackendDeferredToolCallInternalEvent",
|
||||
"AgentBackendError",
|
||||
"AgentBackendHTTPError",
|
||||
@ -73,6 +80,8 @@ __all__ = [
|
||||
"AgentBackendRunRequestBuilder",
|
||||
"AgentBackendRunStartedInternalEvent",
|
||||
"AgentBackendRunSucceededInternalEvent",
|
||||
"AgentBackendSessionCleanupPayload",
|
||||
"AgentBackendSessionCleanupResult",
|
||||
"AgentBackendStreamError",
|
||||
"AgentBackendStreamInternalEvent",
|
||||
"AgentBackendTransportError",
|
||||
@ -82,6 +91,7 @@ __all__ = [
|
||||
"FakeAgentBackendRunClient",
|
||||
"FakeAgentBackendScenario",
|
||||
"RuntimeLayerSpec",
|
||||
"cleanup_agent_backend_session",
|
||||
"create_agent_backend_run_client",
|
||||
"extract_runtime_layer_specs",
|
||||
"redact_for_agent_backend_log",
|
||||
|
||||
@ -5,6 +5,9 @@ The adapter does not define a new cross-service event contract. It consumes
|
||||
workflow Agent Node maps to Graphon/AppQueue events. Deferred external tool calls
|
||||
remain Dify Agent ``run_succeeded`` payloads on the wire; API code turns them
|
||||
into an internal event so workflow pause/session handling stays local to API.
|
||||
Agent-message deltas are exposed as annotations on ``PydanticAIStreamRunEvent``
|
||||
so API code does not have to parse Pydantic AI stream-event internals to
|
||||
preserve streaming. The terminal answer remains the ``run_succeeded`` output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -32,6 +35,7 @@ class AgentBackendInternalEventType(StrEnum):
|
||||
|
||||
RUN_STARTED = "run_started"
|
||||
STREAM_EVENT = "stream_event"
|
||||
AGENT_MESSAGE_DELTA = "agent_message_delta"
|
||||
DEFERRED_TOOL_CALL = "deferred_tool_call"
|
||||
RUN_SUCCEEDED = "run_succeeded"
|
||||
RUN_FAILED = "run_failed"
|
||||
@ -61,6 +65,13 @@ class AgentBackendStreamInternalEvent(AgentBackendInternalEventBase):
|
||||
data: JsonValue
|
||||
|
||||
|
||||
class AgentBackendAgentMessageDeltaInternalEvent(AgentBackendInternalEventBase):
|
||||
"""API-internal agent-message delta emitted independently from raw stream events."""
|
||||
|
||||
type: Literal[AgentBackendInternalEventType.AGENT_MESSAGE_DELTA] = AgentBackendInternalEventType.AGENT_MESSAGE_DELTA
|
||||
delta: str
|
||||
|
||||
|
||||
class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
|
||||
"""API-internal terminal success event carrying final output and session state."""
|
||||
|
||||
@ -99,6 +110,7 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
|
||||
type AgentBackendInternalEvent = Annotated[
|
||||
AgentBackendRunStartedInternalEvent
|
||||
| AgentBackendStreamInternalEvent
|
||||
| AgentBackendAgentMessageDeltaInternalEvent
|
||||
| AgentBackendDeferredToolCallInternalEvent
|
||||
| AgentBackendRunSucceededInternalEvent
|
||||
| AgentBackendRunFailedInternalEvent
|
||||
@ -121,6 +133,14 @@ class AgentBackendRunEventAdapter:
|
||||
)
|
||||
]
|
||||
case PydanticAIStreamRunEvent():
|
||||
if event.agent_message_delta:
|
||||
return [
|
||||
AgentBackendAgentMessageDeltaInternalEvent(
|
||||
run_id=event.run_id,
|
||||
source_event_id=event.id,
|
||||
delta=event.agent_message_delta,
|
||||
)
|
||||
]
|
||||
data = cast(JsonValue, _EVENT_DATA_ADAPTER.dump_python(event.data, mode="json"))
|
||||
event_kind = data.get("event_kind") if isinstance(data, dict) else None
|
||||
return [
|
||||
|
||||
@ -11,8 +11,9 @@ composition-driven.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||
@ -46,7 +47,6 @@ from dify_agent.protocol import (
|
||||
LayerExitSignals,
|
||||
RunComposition,
|
||||
RunLayerSpec,
|
||||
RunPurpose,
|
||||
RuntimeLayerSpec,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
||||
@ -63,6 +63,7 @@ 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"
|
||||
type AgentConfigVersionKind = Literal["snapshot", "draft", "build_draft"]
|
||||
|
||||
|
||||
def _filter_snapshot_to_specs(
|
||||
@ -104,6 +105,59 @@ def _shell_config_with_drive_ref(
|
||||
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
|
||||
|
||||
|
||||
def _markdown_backtick_fence(text: str) -> str:
|
||||
"""Choose a fence that will not terminate inside the prompt body."""
|
||||
longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
|
||||
return "`" * max(3, longest_backtick_run + 1)
|
||||
|
||||
|
||||
_BUILD_DRAFT_AGENT_SOUL_PROMPT = """You are running in build mode.
|
||||
|
||||
Objective:
|
||||
- Improve this agent's working environment, configuration, tools, files, notes,
|
||||
and context so it can handle the intended task well.
|
||||
|
||||
Guidance:
|
||||
- Treat the intended task as context for setup work, validation, and configuration decisions.
|
||||
- Perform concrete investigative or setup steps when they help improve or verify the agent configuration.
|
||||
- Use the installed `dify-agent` CLI when you need to inspect or persist Agent configuration."""
|
||||
|
||||
|
||||
def _wrap_build_draft_agent_soul_prompt(prompt: str | None) -> str:
|
||||
"""Reframe build-draft Agent Soul prompts as preparation work for a future run."""
|
||||
prompt_body = (prompt or "").strip()
|
||||
if not prompt_body:
|
||||
return _BUILD_DRAFT_AGENT_SOUL_PROMPT + "\n\nIntended task for later normal runs:\nNo task prompt was provided."
|
||||
fence = _markdown_backtick_fence(prompt_body)
|
||||
return (
|
||||
_BUILD_DRAFT_AGENT_SOUL_PROMPT
|
||||
+ f"\n\nIntended task for later normal runs:\n{fence}text\n{prompt_body}\n{fence}"
|
||||
)
|
||||
|
||||
|
||||
def _agent_soul_prompt_for_layer(
|
||||
prompt: str | None,
|
||||
*,
|
||||
config_version_kind: AgentConfigVersionKind,
|
||||
) -> str | None:
|
||||
"""Preserve normal snapshot/draft prompts and only wrap build-draft prompts.
|
||||
|
||||
The API-side layer adapter is the product boundary where Agent Soul text
|
||||
becomes the model-facing system-prompt layer. ``snapshot`` and normal
|
||||
``draft`` runs pass through the original effective prompt unchanged, while
|
||||
``build_draft`` always emits a setup prompt. When an original prompt is
|
||||
present, it is reframed as future-run context and embedded in a fenced
|
||||
block; when it is blank, the setup instruction is still kept.
|
||||
"""
|
||||
if config_version_kind != "build_draft":
|
||||
if prompt is None:
|
||||
return None
|
||||
if not prompt.strip():
|
||||
return None
|
||||
return prompt
|
||||
return _wrap_build_draft_agent_soul_prompt(prompt)
|
||||
|
||||
|
||||
class AgentBackendModelConfig(BaseModel):
|
||||
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
|
||||
|
||||
@ -163,7 +217,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
workflow_node_job_prompt: str
|
||||
user_prompt: str
|
||||
agent_soul_prompt: str | None = None
|
||||
purpose: RunPurpose = "workflow_node"
|
||||
agent_config_version_kind: AgentConfigVersionKind = "snapshot"
|
||||
idempotency_key: str | None = None
|
||||
output: AgentBackendOutputConfig | None = None
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
@ -212,7 +266,7 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
user_prompt: str
|
||||
agent_soul_prompt: str | None = None
|
||||
purpose: RunPurpose = "agent_app"
|
||||
agent_config_version_kind: AgentConfigVersionKind = "snapshot"
|
||||
idempotency_key: str | None = None
|
||||
output: AgentBackendOutputConfig | None = None
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
@ -261,13 +315,17 @@ class AgentBackendRunRequestBuilder:
|
||||
prompt.
|
||||
"""
|
||||
layers: list[RunLayerSpec] = []
|
||||
if run_input.agent_soul_prompt:
|
||||
agent_soul_prompt = _agent_soul_prompt_for_layer(
|
||||
run_input.agent_soul_prompt,
|
||||
config_version_kind=run_input.agent_config_version_kind,
|
||||
)
|
||||
if agent_soul_prompt:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=AGENT_SOUL_PROMPT_LAYER_ID,
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "agent_soul"},
|
||||
config=PromptLayerConfig(prefix=run_input.agent_soul_prompt),
|
||||
config=PromptLayerConfig(prefix=agent_soul_prompt),
|
||||
)
|
||||
)
|
||||
|
||||
@ -419,7 +477,6 @@ class AgentBackendRunRequestBuilder:
|
||||
|
||||
return CreateRunRequest(
|
||||
composition=RunComposition(layers=layers),
|
||||
purpose=run_input.purpose,
|
||||
idempotency_key=run_input.idempotency_key,
|
||||
metadata=run_input.metadata,
|
||||
session_snapshot=run_input.session_snapshot,
|
||||
@ -467,7 +524,6 @@ class AgentBackendRunRequestBuilder:
|
||||
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, runtime_layer_specs)
|
||||
return CreateRunRequest(
|
||||
composition=RunComposition(layers=layers),
|
||||
purpose="workflow_node",
|
||||
idempotency_key=idempotency_key,
|
||||
metadata=request_metadata,
|
||||
session_snapshot=filtered_snapshot,
|
||||
@ -483,13 +539,17 @@ class AgentBackendRunRequestBuilder:
|
||||
ask_human / structured output.
|
||||
"""
|
||||
layers: list[RunLayerSpec] = []
|
||||
if run_input.agent_soul_prompt:
|
||||
agent_soul_prompt = _agent_soul_prompt_for_layer(
|
||||
run_input.agent_soul_prompt,
|
||||
config_version_kind=run_input.agent_config_version_kind,
|
||||
)
|
||||
if agent_soul_prompt:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=AGENT_SOUL_PROMPT_LAYER_ID,
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "agent_soul"},
|
||||
config=PromptLayerConfig(prefix=run_input.agent_soul_prompt),
|
||||
config=PromptLayerConfig(prefix=agent_soul_prompt),
|
||||
)
|
||||
)
|
||||
|
||||
@ -649,7 +709,6 @@ class AgentBackendRunRequestBuilder:
|
||||
|
||||
return CreateRunRequest(
|
||||
composition=RunComposition(layers=layers),
|
||||
purpose=run_input.purpose,
|
||||
idempotency_key=run_input.idempotency_key,
|
||||
metadata=run_input.metadata,
|
||||
session_snapshot=run_input.session_snapshot,
|
||||
|
||||
100
api/clients/agent_backend/session_cleanup.py
Normal file
100
api/clients/agent_backend/session_cleanup.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""Shared API-side helper for Agent backend lifecycle-only session cleanup.
|
||||
|
||||
Product code owns local row retirement and background-task dispatch. This module
|
||||
only adapts persisted cleanup inputs into the public ``dify-agent`` run
|
||||
protocol, performs the synchronous ``create_run + wait_run`` loop used by Celery
|
||||
workers, and reports whether the backend cleanup succeeded, was skipped, or
|
||||
failed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.protocol import RuntimeLayerSpec
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
||||
|
||||
from clients.agent_backend.client import AgentBackendRunClient
|
||||
from clients.agent_backend.errors import AgentBackendError
|
||||
from clients.agent_backend.request_builder import AgentBackendRunRequestBuilder
|
||||
|
||||
|
||||
class AgentBackendSessionCleanupPayload(BaseModel):
|
||||
"""Serialized cleanup inputs preserved across API and Celery boundaries."""
|
||||
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
runtime_layer_specs: list[RuntimeLayerSpec] = Field(default_factory=list)
|
||||
idempotency_key: str | None = None
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
timeout_seconds: float = 30.0
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentBackendSessionCleanupResult:
|
||||
"""Terminal outcome of one backend cleanup attempt."""
|
||||
|
||||
status: Literal["succeeded", "skipped", "failed"]
|
||||
reason: str | None = None
|
||||
cleanup_run_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def succeeded(cls, cleanup_run_id: str) -> AgentBackendSessionCleanupResult:
|
||||
return cls(status="succeeded", cleanup_run_id=cleanup_run_id)
|
||||
|
||||
@classmethod
|
||||
def skipped(cls, reason: str) -> AgentBackendSessionCleanupResult:
|
||||
return cls(status="skipped", reason=reason)
|
||||
|
||||
@classmethod
|
||||
def failed(cls, reason: str, cleanup_run_id: str | None = None) -> AgentBackendSessionCleanupResult:
|
||||
return cls(status="failed", reason=reason, cleanup_run_id=cleanup_run_id)
|
||||
|
||||
|
||||
def cleanup_agent_backend_session(
|
||||
*,
|
||||
payload: AgentBackendSessionCleanupPayload,
|
||||
client: AgentBackendRunClient | None,
|
||||
request_builder: AgentBackendRunRequestBuilder | None = None,
|
||||
) -> AgentBackendSessionCleanupResult:
|
||||
"""Run lifecycle-only cleanup against the Agent backend and report status."""
|
||||
if client is None:
|
||||
return AgentBackendSessionCleanupResult.skipped("no_agent_backend_client")
|
||||
if payload.session_snapshot is None:
|
||||
return AgentBackendSessionCleanupResult.skipped("missing_session_snapshot")
|
||||
if not payload.runtime_layer_specs:
|
||||
return AgentBackendSessionCleanupResult.skipped("missing_runtime_layer_specs")
|
||||
|
||||
builder = request_builder or AgentBackendRunRequestBuilder()
|
||||
request = builder.build_cleanup_request(
|
||||
session_snapshot=payload.session_snapshot,
|
||||
runtime_layer_specs=payload.runtime_layer_specs,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
metadata=payload.metadata,
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.create_run(request)
|
||||
except AgentBackendError as exc:
|
||||
return AgentBackendSessionCleanupResult.failed(str(exc))
|
||||
|
||||
try:
|
||||
status_response = client.wait_run(response.run_id, timeout_seconds=payload.timeout_seconds)
|
||||
except AgentBackendError as exc:
|
||||
return AgentBackendSessionCleanupResult.failed(str(exc), cleanup_run_id=response.run_id)
|
||||
|
||||
if status_response.status != "succeeded":
|
||||
reason = status_response.error or f"cleanup run ended with status {status_response.status}"
|
||||
return AgentBackendSessionCleanupResult.failed(reason, cleanup_run_id=response.run_id)
|
||||
|
||||
return AgentBackendSessionCleanupResult.succeeded(response.run_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentBackendSessionCleanupPayload",
|
||||
"AgentBackendSessionCleanupResult",
|
||||
"cleanup_agent_backend_session",
|
||||
]
|
||||
@ -36,7 +36,7 @@ from controllers.console.wraps import (
|
||||
with_current_user_id,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.app_invoke_entities import AGENT_RUNTIME_EXIT_INTENT_ARG, InvokeFrom
|
||||
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
|
||||
from core.errors.error import (
|
||||
ModelCurrentlyNotSupportError,
|
||||
@ -416,6 +416,7 @@ def _create_build_chat_finalization_message(
|
||||
"draft_type": "debug_build",
|
||||
"conversation_id": debug_conversation_id,
|
||||
"auto_generate_name": False,
|
||||
AGENT_RUNTIME_EXIT_INTENT_ARG: "delete",
|
||||
}
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
"""Agent App generator: orchestrate Agent App chat and finalize executions.
|
||||
|
||||
The primary mode mirrors the agent_chat generator (conversation + message +
|
||||
Agent App turns mirror 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.
|
||||
LLM/ReAct loop. Build-chat finalization uses this same streamed path and only
|
||||
changes the runtime exit policy carried to the backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -41,13 +38,16 @@ from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
|
||||
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
AGENT_RUNTIME_EXIT_INTENT_ARG,
|
||||
AgentAppGenerateEntity,
|
||||
AgentRuntimeExitIntent,
|
||||
DifyRunContext,
|
||||
InvokeFrom,
|
||||
UserFrom,
|
||||
)
|
||||
from core.app.llm.model_access import build_dify_model_access
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from core.workflow.file_reference import build_file_reference, is_canonical_file_reference
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App, EndUser, Message
|
||||
from models.agent import (
|
||||
@ -64,12 +64,68 @@ from services.conversation_service import ConversationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REFERENCE_FILE_TRANSFER_METHODS = {"local_file", "tool_file", "datasource_file"}
|
||||
|
||||
|
||||
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:
|
||||
"""Append labeled, prompt-safe file locators to the backend user prompt."""
|
||||
prompt_files = _prompt_file_locators(prompt_file_mappings)
|
||||
if not prompt_files:
|
||||
return query
|
||||
return f"{query}\n{json.dumps(list(prompt_file_mappings), ensure_ascii=False)}"
|
||||
payload = json.dumps(prompt_files, ensure_ascii=False, separators=(",", ":"))
|
||||
return (
|
||||
f"{query}\n"
|
||||
"User provided files: use dify-agent file download with the listed transfer_method and reference/url "
|
||||
"to get the files and investigate them\n"
|
||||
f"{payload}"
|
||||
)
|
||||
|
||||
|
||||
def _prompt_file_locators(prompt_file_mappings: Sequence[JsonValue]) -> list[dict[str, str]]:
|
||||
locators: list[dict[str, str]] = []
|
||||
for file_mapping in prompt_file_mappings:
|
||||
if not isinstance(file_mapping, Mapping):
|
||||
continue
|
||||
locator = _prompt_file_locator(file_mapping)
|
||||
if locator is not None:
|
||||
locators.append(locator)
|
||||
return locators
|
||||
|
||||
|
||||
def _prompt_file_locator(file_mapping: Mapping[str, object]) -> dict[str, str] | None:
|
||||
transfer_method = _string_value(file_mapping, "transfer_method")
|
||||
if transfer_method == "remote_url":
|
||||
url = _string_value(file_mapping, "url") or _string_value(file_mapping, "remote_url")
|
||||
if url is None:
|
||||
return None
|
||||
return {"transfer_method": "remote_url", "url": url}
|
||||
elif transfer_method in _REFERENCE_FILE_TRANSFER_METHODS:
|
||||
if transfer_method is None:
|
||||
return None
|
||||
reference = _canonical_file_reference(
|
||||
_string_value(file_mapping, "reference")
|
||||
or _string_value(file_mapping, "upload_file_id")
|
||||
or _string_value(file_mapping, "file_id")
|
||||
or _string_value(file_mapping, "id")
|
||||
)
|
||||
if reference is None:
|
||||
return None
|
||||
return {"transfer_method": transfer_method, "reference": reference}
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _canonical_file_reference(reference: str | None) -> str | None:
|
||||
if reference is None:
|
||||
return None
|
||||
if reference.startswith("dify-file-ref:"):
|
||||
return reference if is_canonical_file_reference(reference) else None
|
||||
return build_file_reference(record_id=reference)
|
||||
|
||||
|
||||
def _string_value(mapping: Mapping[str, object], key: str) -> str | None:
|
||||
value = mapping.get(key)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
@ -120,6 +176,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
model_conf = ModelConfigConverter.convert(app_config)
|
||||
|
||||
trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id)
|
||||
agent_runtime_exit_intent = self._resolve_agent_runtime_exit_intent(args)
|
||||
|
||||
application_generate_entity = AgentAppGenerateEntity(
|
||||
task_id=str(uuid.uuid4()),
|
||||
@ -149,6 +206,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
agent_config_snapshot_id=agent_config_id,
|
||||
agent_config_version_kind=agent_config_version_kind,
|
||||
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
|
||||
agent_runtime_exit_intent=agent_runtime_exit_intent,
|
||||
)
|
||||
|
||||
conversation, message = self._init_generate_records(application_generate_entity, conversation)
|
||||
@ -187,86 +245,6 @@ 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,
|
||||
*,
|
||||
@ -476,6 +454,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
model_name=application_generate_entity.model_conf.model,
|
||||
queue_manager=queue_manager,
|
||||
session_scope_snapshot_id=application_generate_entity.agent_runtime_session_snapshot_id,
|
||||
agent_runtime_exit_intent=application_generate_entity.agent_runtime_exit_intent,
|
||||
)
|
||||
except GenerateTaskStoppedError:
|
||||
pass
|
||||
@ -492,6 +471,18 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
raise AgentAppGeneratorError("query is required")
|
||||
return query.replace("\x00", "")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_runtime_exit_intent(args: Mapping[str, Any]) -> AgentRuntimeExitIntent:
|
||||
"""Resolve API-internal runtime exit policy from controller-owned args.
|
||||
|
||||
Only the private controller-injected "delete" value changes behavior.
|
||||
Normal chat and resume flows default/fallback to "suspend" so public
|
||||
payloads and invalid internal values preserve existing semantics.
|
||||
"""
|
||||
if args.get(AGENT_RUNTIME_EXIT_INTENT_ARG) == "delete":
|
||||
return "delete"
|
||||
return "suspend"
|
||||
|
||||
@staticmethod
|
||||
def _build_runner(dify_context: DifyRunContext) -> AgentAppRunner:
|
||||
credentials_provider, _ = build_dify_model_access(dify_context)
|
||||
|
||||
@ -1,15 +1,10 @@
|
||||
"""Agent App runner: drive Agent backend turns for both chat and finalize flows.
|
||||
"""Agent App runner: drive Agent backend turns for chat and finalization flows.
|
||||
|
||||
Unlike the legacy ``AgentChatAppRunner`` (which runs an in-process ReAct loop),
|
||||
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.
|
||||
this runner delegates to the Agent backend, consumes the streamed event flow,
|
||||
republishes the assistant answer through the existing EasyUI chat task
|
||||
pipeline, and then either saves or retires the conversation-owned runtime
|
||||
session depending on the turn's exit policy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -26,6 +21,7 @@ from dify_agent.protocol import DeferredToolResultsPayload
|
||||
from pydantic import JsonValue
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendAgentMessageDeltaInternalEvent,
|
||||
AgentBackendDeferredToolCallInternalEvent,
|
||||
AgentBackendError,
|
||||
AgentBackendInternalEventType,
|
||||
@ -35,7 +31,7 @@ from clients.agent_backend import (
|
||||
AgentBackendStreamInternalEvent,
|
||||
extract_runtime_layer_specs,
|
||||
)
|
||||
from configs import dify_config
|
||||
from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload
|
||||
from core.app.apps.agent_app.runtime_request_builder import (
|
||||
AgentAppRuntimeBuildContext,
|
||||
AgentAppRuntimeRequest,
|
||||
@ -48,8 +44,13 @@ 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 QueueAgentThoughtEvent, QueueLLMChunkEvent, QueueMessageEndEvent
|
||||
from core.app.entities.app_invoke_entities import AgentRuntimeExitIntent, DifyRunContext
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueAgentMessageEvent,
|
||||
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
|
||||
@ -59,6 +60,7 @@ from graphon.model_runtime.entities.message_entities import AssistantPromptMessa
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import MessageAgentThought
|
||||
from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -135,6 +137,25 @@ def publish_text_delta(
|
||||
queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER)
|
||||
|
||||
|
||||
def publish_agent_message_delta(
|
||||
*,
|
||||
queue_manager: AppQueueManager,
|
||||
model_name: str,
|
||||
delta: str,
|
||||
user_query: str | None = None,
|
||||
) -> None:
|
||||
"""Publish one agent-process text delta through the EasyUI chat pipeline."""
|
||||
if not delta:
|
||||
return
|
||||
prompt_messages = _prompt_messages_from_query(user_query)
|
||||
chunk = LLMResultChunk(
|
||||
model=model_name,
|
||||
prompt_messages=prompt_messages,
|
||||
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=delta)),
|
||||
)
|
||||
queue_manager.publish(QueueAgentMessageEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER)
|
||||
|
||||
|
||||
def publish_message_end(
|
||||
*,
|
||||
queue_manager: AppQueueManager,
|
||||
@ -159,7 +180,7 @@ def publish_message_end(
|
||||
|
||||
|
||||
class _TextDeltaDebouncer:
|
||||
"""Batch assistant text deltas on stream-event boundaries for final SSE output."""
|
||||
"""Batch independent model text deltas before agent-message SSE output."""
|
||||
|
||||
def __init__(self, *, debounce_seconds: float) -> None:
|
||||
self._debounce_seconds = debounce_seconds
|
||||
@ -192,7 +213,13 @@ class _TextDeltaDebouncer:
|
||||
|
||||
|
||||
class _AgentProcessRecorder:
|
||||
"""Persist Agent v2 thinking/tool process events through the legacy thought model."""
|
||||
"""Persist Agent v2 process streams through the legacy thought model.
|
||||
|
||||
Thinking and answer rows expose snapshot updates for contiguous model-text
|
||||
segments. Tool events close currently open text segments so later model text
|
||||
starts a fresh row instead of replaying content that was already streamed
|
||||
before the tool.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -206,6 +233,7 @@ class _AgentProcessRecorder:
|
||||
self._queue_manager = queue_manager
|
||||
self._next_position = 1
|
||||
self._thinking_by_index: dict[int, str] = {}
|
||||
self._answer_thought_id: str | None = None
|
||||
self._tool_by_index: dict[int, str] = {}
|
||||
self._tool_by_call_id: dict[str, str] = {}
|
||||
self._open_tool_by_name: dict[str, set[str]] = {}
|
||||
@ -261,6 +289,41 @@ class _AgentProcessRecorder:
|
||||
if part_kind in {"tool-return", "builtin-tool-return"}:
|
||||
self._record_tool_return_part(part)
|
||||
|
||||
def append_answer_text(self, content_delta: str) -> None:
|
||||
if not content_delta:
|
||||
return
|
||||
|
||||
self._thinking_by_index.clear()
|
||||
if self._answer_thought_id is None:
|
||||
self._answer_thought_id = self._create_thought(answer=content_delta)
|
||||
return
|
||||
self._update_thought(self._answer_thought_id, answer_delta=content_delta)
|
||||
|
||||
def trim_answer_suffix(self, final_answer: str) -> None:
|
||||
if not final_answer or self._answer_thought_id is None:
|
||||
return
|
||||
|
||||
row = db.session.get(MessageAgentThought, self._answer_thought_id)
|
||||
if row is None:
|
||||
return
|
||||
|
||||
answer = row.answer or ""
|
||||
overlap = _suffix_prefix_overlap_length(answer, final_answer)
|
||||
if overlap == 0:
|
||||
return
|
||||
|
||||
row.answer = answer[:-overlap]
|
||||
if _is_empty_answer_only_thought(row):
|
||||
db.session.delete(row)
|
||||
self._answer_thought_id = None
|
||||
db.session.commit()
|
||||
return
|
||||
|
||||
db.session.commit()
|
||||
self._queue_manager.publish(
|
||||
QueueAgentThoughtEvent(agent_thought_id=self._answer_thought_id), PublishFrom.APPLICATION_MANAGER
|
||||
)
|
||||
|
||||
def _handle_tool_call_event(self, data: dict[str, Any]) -> None:
|
||||
part = data.get("part")
|
||||
if isinstance(part, dict):
|
||||
@ -281,6 +344,7 @@ class _AgentProcessRecorder:
|
||||
)
|
||||
|
||||
def _append_thinking(self, index: int, content_delta: str) -> None:
|
||||
self._answer_thought_id = None
|
||||
thought_id = self._thinking_by_index.get(index)
|
||||
if thought_id is None:
|
||||
thought_id = self._create_thought(thought=content_delta)
|
||||
@ -289,6 +353,7 @@ class _AgentProcessRecorder:
|
||||
self._update_thought(thought_id, thought_delta=content_delta)
|
||||
|
||||
def _record_tool_call_delta(self, index: int, delta: dict[str, Any]) -> None:
|
||||
self._close_thinking_segments()
|
||||
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")
|
||||
@ -305,8 +370,10 @@ class _AgentProcessRecorder:
|
||||
tool=tool_name,
|
||||
tool_input_delta=_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)
|
||||
|
||||
def _record_tool_call_part(self, index: int, part: dict[str, Any]) -> None:
|
||||
self._close_thinking_segments()
|
||||
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)
|
||||
@ -322,8 +389,10 @@ class _AgentProcessRecorder:
|
||||
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)
|
||||
|
||||
def _record_tool_return_part(self, part: dict[str, Any]) -> None:
|
||||
self._close_thinking_segments()
|
||||
tool_call_id = _string_or_none(part.get("tool_call_id"))
|
||||
tool_name = _string_or_none(part.get("tool_name"))
|
||||
content = part.get("content")
|
||||
@ -332,6 +401,7 @@ class _AgentProcessRecorder:
|
||||
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:
|
||||
self._close_thinking_segments()
|
||||
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)
|
||||
@ -366,8 +436,17 @@ class _AgentProcessRecorder:
|
||||
for open_thought_ids in self._open_tool_by_name.values():
|
||||
open_thought_ids.discard(thought_id)
|
||||
|
||||
def _close_thinking_segments(self) -> None:
|
||||
self._thinking_by_index.clear()
|
||||
self._answer_thought_id = None
|
||||
|
||||
def _create_thought(
|
||||
self, *, thought: str | None = None, tool: str | None = None, tool_input: str | None = None
|
||||
self,
|
||||
*,
|
||||
thought: str | None = None,
|
||||
answer: str | None = None,
|
||||
tool: str | None = None,
|
||||
tool_input: str | None = None,
|
||||
) -> str:
|
||||
row = MessageAgentThought(
|
||||
message_id=self._message_id,
|
||||
@ -384,7 +463,7 @@ class _AgentProcessRecorder:
|
||||
message_unit_price=Decimal(0),
|
||||
message_price_unit=Decimal("0.001"),
|
||||
message_files="",
|
||||
answer="",
|
||||
answer=answer or "",
|
||||
answer_token=0,
|
||||
answer_unit_price=Decimal(0),
|
||||
answer_price_unit=Decimal("0.001"),
|
||||
@ -414,6 +493,7 @@ class _AgentProcessRecorder:
|
||||
tool_input: str | None = None,
|
||||
tool_input_delta: str | None = None,
|
||||
observation: str | None = None,
|
||||
answer_delta: str | None = None,
|
||||
) -> None:
|
||||
row = db.session.get(MessageAgentThought, thought_id)
|
||||
if row is None:
|
||||
@ -430,6 +510,8 @@ class _AgentProcessRecorder:
|
||||
row.tool_input = f"{row.tool_input or ''}{tool_input_delta}"
|
||||
if observation is not None:
|
||||
row.observation = observation
|
||||
if answer_delta:
|
||||
row.answer = f"{row.answer or ''}{answer_delta}"
|
||||
|
||||
db.session.commit()
|
||||
self._queue_manager.publish(
|
||||
@ -468,6 +550,18 @@ def _tool_labels(tool: str | None) -> str:
|
||||
return json.dumps({tool: {"en_US": tool, "zh_Hans": tool}}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _suffix_prefix_overlap_length(text: str, prefix_source: str) -> int:
|
||||
max_length = min(len(text), len(prefix_source))
|
||||
for length in range(max_length, 0, -1):
|
||||
if text.endswith(prefix_source[:length]):
|
||||
return length
|
||||
return 0
|
||||
|
||||
|
||||
def _is_empty_answer_only_thought(row: MessageAgentThought) -> bool:
|
||||
return not any((row.thought, row.answer, row.tool, row.tool_input, row.observation))
|
||||
|
||||
|
||||
class AgentAppRunner:
|
||||
"""Runs one Agent App conversation turn against the Agent backend."""
|
||||
|
||||
@ -500,7 +594,9 @@ class AgentAppRunner:
|
||||
model_name: str,
|
||||
queue_manager: AppQueueManager,
|
||||
session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID,
|
||||
agent_runtime_exit_intent: AgentRuntimeExitIntent = "suspend",
|
||||
) -> None:
|
||||
preserve_session = agent_runtime_exit_intent == "suspend"
|
||||
scope = self._build_session_scope(
|
||||
dify_context=dify_context,
|
||||
agent_id=agent_id,
|
||||
@ -522,10 +618,11 @@ class AgentAppRunner:
|
||||
idempotency_key=message_id,
|
||||
stored=stored,
|
||||
message_id=message_id,
|
||||
suspend_on_exit=preserve_session,
|
||||
)
|
||||
|
||||
create_response = self._agent_backend_client.create_run(runtime.request)
|
||||
terminal, streamed_answer = self._consume_stream(
|
||||
terminal, process_recorder = self._consume_stream(
|
||||
create_response.run_id,
|
||||
dify_context=dify_context,
|
||||
message_id=message_id,
|
||||
@ -535,6 +632,9 @@ class AgentAppRunner:
|
||||
)
|
||||
|
||||
if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent):
|
||||
if not preserve_session:
|
||||
self._mark_session_cleaned(scope=scope, backend_run_id=terminal.run_id)
|
||||
raise AgentBackendError("Agent App finalization cannot pause for human input.")
|
||||
# ENG-635: the agent asked a human. End this turn with the question and
|
||||
# a conversation-owned HITL form; a form submission resumes the run.
|
||||
self._pause_for_ask_human(
|
||||
@ -555,70 +655,49 @@ class AgentAppRunner:
|
||||
error = getattr(terminal, "error", None) or "Agent backend run did not complete successfully."
|
||||
raise AgentBackendError(str(error))
|
||||
|
||||
answer = self._extract_answer(terminal.output)
|
||||
self._publish_terminal_answer(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=answer,
|
||||
query=query,
|
||||
streamed_answer=streamed_answer,
|
||||
usage=_llm_usage_from_agent_backend(terminal.usage),
|
||||
)
|
||||
self._save_session(
|
||||
scope=scope,
|
||||
backend_run_id=terminal.run_id,
|
||||
snapshot=terminal.session_snapshot,
|
||||
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))
|
||||
answer = self._terminal_output_to_answer(terminal.output)
|
||||
try:
|
||||
process_recorder.trim_answer_suffix(answer)
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
logger.warning(
|
||||
"Failed to trim Agent App answer text: run_id=%s message_id=%s",
|
||||
terminal.run_id,
|
||||
message_id,
|
||||
exc_info=True,
|
||||
)
|
||||
if preserve_session:
|
||||
superseded_sessions = self._load_superseded_sessions(scope=scope)
|
||||
self._publish_terminal_answer(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=answer,
|
||||
query=query,
|
||||
usage=_llm_usage_from_agent_backend(terminal.usage),
|
||||
)
|
||||
session_saved = self._save_session(
|
||||
scope=scope,
|
||||
backend_run_id=terminal.run_id,
|
||||
snapshot=terminal.session_snapshot,
|
||||
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
|
||||
)
|
||||
if session_saved:
|
||||
self._cleanup_superseded_sessions(superseded_sessions)
|
||||
else:
|
||||
# The backend has already accepted a terminal success with
|
||||
# delete-on-exit semantics. Local publish/persistence errors must
|
||||
# not keep the API-side session row active, and cleanup failures
|
||||
# must not replace the original publish/error outcome.
|
||||
try:
|
||||
self._publish_terminal_answer(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=answer,
|
||||
query=query,
|
||||
usage=_llm_usage_from_agent_backend(terminal.usage),
|
||||
)
|
||||
finally:
|
||||
self._mark_session_cleaned(scope=scope, backend_run_id=terminal.run_id)
|
||||
|
||||
def _build_session_scope(
|
||||
self,
|
||||
@ -654,6 +733,7 @@ class AgentAppRunner:
|
||||
idempotency_key: str,
|
||||
stored: StoredAgentAppSession | None,
|
||||
message_id: str | None,
|
||||
suspend_on_exit: bool,
|
||||
) -> AgentAppRuntimeRequest:
|
||||
session_snapshot = stored.session_snapshot if stored is not None else None
|
||||
deferred_tool_results = (
|
||||
@ -673,6 +753,7 @@ class AgentAppRunner:
|
||||
idempotency_key=idempotency_key,
|
||||
session_snapshot=session_snapshot,
|
||||
deferred_tool_results=deferred_tool_results,
|
||||
suspend_on_exit=suspend_on_exit,
|
||||
)
|
||||
)
|
||||
|
||||
@ -775,46 +856,61 @@ class AgentAppRunner:
|
||||
model_name: str,
|
||||
query: str | None,
|
||||
):
|
||||
"""Consume backend events while preserving raw recorder granularity.
|
||||
|
||||
Process events are recorded immediately for observability. Only the
|
||||
final assistant text deltas sent through the EasyUI queue are debounced,
|
||||
with flushes happening on later stream events or terminal boundaries.
|
||||
"""
|
||||
"""Consume backend events while preserving raw recorder granularity."""
|
||||
terminal = None
|
||||
streamed_answer_parts: list[str] = []
|
||||
text_delta_debouncer = _TextDeltaDebouncer(debounce_seconds=self._text_delta_debounce_seconds)
|
||||
process_recorder = _AgentProcessRecorder(
|
||||
dify_context=dify_context,
|
||||
message_id=message_id,
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
text_delta_debouncer = _TextDeltaDebouncer(debounce_seconds=self._text_delta_debounce_seconds)
|
||||
|
||||
def flush_pending_text() -> None:
|
||||
def persist_answer_text(content_delta: str) -> None:
|
||||
try:
|
||||
process_recorder.append_answer_text(content_delta)
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
logger.warning(
|
||||
"Failed to persist Agent App answer text: run_id=%s message_id=%s",
|
||||
run_id,
|
||||
message_id,
|
||||
exc_info=True,
|
||||
)
|
||||
publish_agent_message_delta(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
delta=content_delta,
|
||||
user_query=query,
|
||||
)
|
||||
|
||||
def flush_pending_agent_message_text() -> None:
|
||||
pending_text = text_delta_debouncer.flush()
|
||||
if pending_text:
|
||||
publish_text_delta(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
delta=pending_text,
|
||||
user_query=query,
|
||||
)
|
||||
persist_answer_text(pending_text)
|
||||
|
||||
for public_event in self._agent_backend_client.stream_events(run_id):
|
||||
if queue_manager.is_stopped():
|
||||
flush_pending_text()
|
||||
flush_pending_agent_message_text()
|
||||
self._cancel_run(run_id)
|
||||
raise GenerateTaskStoppedError()
|
||||
for internal_event in self._event_adapter.adapt(public_event):
|
||||
if queue_manager.is_stopped():
|
||||
flush_pending_text()
|
||||
flush_pending_agent_message_text()
|
||||
self._cancel_run(run_id)
|
||||
raise GenerateTaskStoppedError()
|
||||
if internal_event.type in (
|
||||
AgentBackendInternalEventType.RUN_STARTED,
|
||||
AgentBackendInternalEventType.STREAM_EVENT,
|
||||
AgentBackendInternalEventType.AGENT_MESSAGE_DELTA,
|
||||
):
|
||||
if isinstance(internal_event, AgentBackendAgentMessageDeltaInternalEvent):
|
||||
debounced_delta = text_delta_debouncer.push(internal_event.delta)
|
||||
if debounced_delta:
|
||||
persist_answer_text(debounced_delta)
|
||||
continue
|
||||
|
||||
if isinstance(internal_event, AgentBackendStreamInternalEvent):
|
||||
flush_pending_agent_message_text()
|
||||
try:
|
||||
process_recorder.handle_stream_event(internal_event)
|
||||
except Exception:
|
||||
@ -826,26 +922,15 @@ class AgentAppRunner:
|
||||
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)
|
||||
debounced_delta = text_delta_debouncer.push(text_delta)
|
||||
if debounced_delta:
|
||||
publish_text_delta(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
delta=debounced_delta,
|
||||
user_query=query,
|
||||
)
|
||||
continue
|
||||
continue
|
||||
flush_pending_text()
|
||||
flush_pending_agent_message_text()
|
||||
terminal = internal_event
|
||||
break
|
||||
if terminal is not None:
|
||||
break
|
||||
flush_pending_text()
|
||||
return terminal, "".join(streamed_answer_parts)
|
||||
flush_pending_agent_message_text()
|
||||
return terminal, process_recorder
|
||||
|
||||
def _cancel_run(self, run_id: str) -> None:
|
||||
try:
|
||||
@ -867,42 +952,15 @@ class AgentAppRunner:
|
||||
model_name: str,
|
||||
answer: str,
|
||||
query: str | None,
|
||||
streamed_answer: str,
|
||||
usage: LLMUsage | None,
|
||||
) -> None:
|
||||
"""Finish a successful streamed turn without duplicating the final text."""
|
||||
if not answer and streamed_answer:
|
||||
answer = streamed_answer
|
||||
|
||||
if not streamed_answer:
|
||||
publish_text_answer(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=answer,
|
||||
user_query=query,
|
||||
usage=usage,
|
||||
)
|
||||
return
|
||||
|
||||
if answer.startswith(streamed_answer):
|
||||
publish_text_delta(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
delta=answer[len(streamed_answer) :],
|
||||
user_query=query,
|
||||
)
|
||||
elif answer != streamed_answer:
|
||||
logger.warning(
|
||||
"Agent App streamed answer does not match terminal output; "
|
||||
"using terminal output for message persistence."
|
||||
)
|
||||
|
||||
publish_message_end(
|
||||
"""Finish a successful turn from the backend terminal output."""
|
||||
publish_text_answer(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=answer,
|
||||
user_query=query,
|
||||
usage=usage,
|
||||
user_query=query,
|
||||
)
|
||||
|
||||
def _save_session(
|
||||
@ -914,7 +972,7 @@ class AgentAppRunner:
|
||||
runtime_layer_specs: Any,
|
||||
pending_form_id: str | None = None,
|
||||
pending_tool_call_id: str | None = None,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
try:
|
||||
self._session_store.save_active_snapshot(
|
||||
scope=scope,
|
||||
@ -924,6 +982,7 @@ class AgentAppRunner:
|
||||
pending_form_id=pending_form_id,
|
||||
pending_tool_call_id=pending_tool_call_id,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist Agent App conversation session snapshot: "
|
||||
@ -934,9 +993,91 @@ class AgentAppRunner:
|
||||
scope.agent_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
def _load_superseded_sessions(self, *, scope: AgentAppSessionScope) -> list[StoredAgentAppSession]:
|
||||
try:
|
||||
stored_sessions = self._session_store.list_active_sessions_for_conversation(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
conversation_id=scope.conversation_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to load existing Agent App conversation sessions before snapshot save: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s",
|
||||
scope.tenant_id,
|
||||
scope.app_id,
|
||||
scope.conversation_id,
|
||||
scope.agent_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
return [stored for stored in stored_sessions if stored.scope != scope]
|
||||
|
||||
def _cleanup_superseded_sessions(self, stored_sessions: list[StoredAgentAppSession]) -> None:
|
||||
for stored_session in stored_sessions:
|
||||
try:
|
||||
if stored_session.runtime_layer_specs:
|
||||
payload = AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||
idempotency_key=(
|
||||
f"{stored_session.scope.tenant_id}:{stored_session.scope.app_id}:"
|
||||
f"{stored_session.scope.conversation_id}:{stored_session.scope.agent_id}:"
|
||||
f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:"
|
||||
f"superseded-session-cleanup:{stored_session.backend_run_id or 'no-run'}"
|
||||
),
|
||||
metadata={
|
||||
"tenant_id": stored_session.scope.tenant_id,
|
||||
"app_id": stored_session.scope.app_id,
|
||||
"conversation_id": stored_session.scope.conversation_id,
|
||||
"agent_id": stored_session.scope.agent_id,
|
||||
"agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id,
|
||||
"previous_agent_backend_run_id": stored_session.backend_run_id,
|
||||
},
|
||||
)
|
||||
cleanup_conversation_agent_runtime_session.delay(payload.model_dump(mode="json"))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to enqueue Agent backend cleanup for superseded Agent App session: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||
stored_session.scope.tenant_id,
|
||||
stored_session.scope.app_id,
|
||||
stored_session.scope.conversation_id,
|
||||
stored_session.scope.agent_id,
|
||||
stored_session.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _mark_session_cleaned(
|
||||
self,
|
||||
*,
|
||||
scope: AgentAppSessionScope,
|
||||
backend_run_id: str,
|
||||
) -> None:
|
||||
"""Best-effort delete-on-exit cleanup for the API-side session row.
|
||||
|
||||
Once the Agent backend reaches a terminal event, cleanup persistence
|
||||
must not replace the original publish/error outcome for that turn.
|
||||
"""
|
||||
try:
|
||||
self._session_store.mark_cleaned(scope=scope, backend_run_id=backend_run_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to retire Agent App conversation session after delete-on-exit: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||
scope.tenant_id,
|
||||
scope.app_id,
|
||||
scope.conversation_id,
|
||||
scope.agent_id,
|
||||
backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_answer(output: JsonValue) -> str:
|
||||
def _terminal_output_to_answer(output: JsonValue) -> str:
|
||||
"""Normalize the backend's terminal output to assistant text.
|
||||
|
||||
Free-text Agent Apps return a plain string; if a structured output is
|
||||
@ -954,27 +1095,5 @@ class AgentAppRunner:
|
||||
return json.dumps(output, ensure_ascii=False)
|
||||
return json.dumps(output, ensure_ascii=False)
|
||||
|
||||
@staticmethod
|
||||
def _extract_stream_text_delta(event: AgentBackendStreamInternalEvent) -> str | None:
|
||||
data = event.data
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
if data.get("event_kind") == "part_delta":
|
||||
delta = data.get("delta")
|
||||
if isinstance(delta, dict) and delta.get("part_delta_kind") == "text":
|
||||
content_delta = delta.get("content_delta")
|
||||
if isinstance(content_delta, str):
|
||||
return content_delta
|
||||
|
||||
if data.get("event_kind") == "part_start":
|
||||
part = data.get("part")
|
||||
if isinstance(part, dict) and part.get("part_kind") == "text":
|
||||
content = part.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["AgentAppRunner", "publish_message_end", "publish_text_answer", "publish_text_delta"]
|
||||
|
||||
@ -74,6 +74,7 @@ class AgentAppRuntimeBuildContext:
|
||||
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
|
||||
suspend_on_exit: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -163,6 +164,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
# no frontend-internal {{#…#}} marker ever reaches the model.
|
||||
agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||
or None,
|
||||
agent_config_version_kind=context.agent_config_version_kind,
|
||||
user_prompt=context.user_query,
|
||||
tools=tool_layers.plugin_tools,
|
||||
core_tools=tool_layers.core_tools,
|
||||
@ -173,6 +175,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
shell_config=build_shell_layer_config(agent_soul),
|
||||
session_snapshot=context.session_snapshot,
|
||||
deferred_tool_results=context.deferred_tool_results,
|
||||
suspend_on_exit=context.suspend_on_exit,
|
||||
idempotency_key=context.idempotency_key,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@ -124,6 +124,41 @@ class AgentAppRuntimeSessionStore:
|
||||
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
|
||||
)
|
||||
|
||||
def list_active_sessions_for_conversation(
|
||||
self, *, tenant_id: str, app_id: str, conversation_id: str
|
||||
) -> list[StoredAgentAppSession]:
|
||||
"""List all ACTIVE conversation-owned sessions for lifecycle cleanup."""
|
||||
stmt = (
|
||||
select(AgentRuntimeSession)
|
||||
.where(
|
||||
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
AgentRuntimeSession.tenant_id == tenant_id,
|
||||
AgentRuntimeSession.app_id == app_id,
|
||||
AgentRuntimeSession.conversation_id == conversation_id,
|
||||
AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
.order_by(AgentRuntimeSession.updated_at.desc())
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
rows = session.scalars(stmt).all()
|
||||
return [
|
||||
StoredAgentAppSession(
|
||||
scope=AgentAppSessionScope(
|
||||
tenant_id=row.tenant_id,
|
||||
app_id=row.app_id,
|
||||
conversation_id=row.conversation_id or "",
|
||||
agent_id=row.agent_id,
|
||||
agent_config_snapshot_id=row.agent_config_snapshot_id,
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||
backend_run_id=row.backend_run_id,
|
||||
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
|
||||
pending_form_id=row.pending_form_id,
|
||||
pending_tool_call_id=row.pending_tool_call_id,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def save_active_snapshot(
|
||||
self,
|
||||
*,
|
||||
@ -134,6 +169,14 @@ class AgentAppRuntimeSessionStore:
|
||||
pending_form_id: str | None = None,
|
||||
pending_tool_call_id: str | None = None,
|
||||
) -> None:
|
||||
"""Persist the current conversation snapshot and enforce one ACTIVE row.
|
||||
|
||||
Agent App chat treats one conversation as one resumable runtime shell.
|
||||
Saving the latest snapshot therefore upserts the scoped row back to
|
||||
ACTIVE and retires any other ACTIVE conversation-owned rows for the
|
||||
same ``tenant_id + app_id + conversation_id`` so later lookups see a
|
||||
single active session.
|
||||
"""
|
||||
if snapshot is None:
|
||||
return
|
||||
snapshot_json = snapshot.model_dump_json()
|
||||
|
||||
@ -15,6 +15,8 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
DIFY_RUN_CONTEXT_KEY = "_dify"
|
||||
AGENT_RUNTIME_EXIT_INTENT_ARG = "_agent_runtime_exit_intent"
|
||||
type AgentRuntimeExitIntent = Literal["suspend", "delete"]
|
||||
|
||||
|
||||
class UserFrom(StrEnum):
|
||||
@ -228,6 +230,10 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity):
|
||||
``agent_runtime_session_snapshot_id`` carries the runtime session scope
|
||||
used to resume or suspend within the same editable config surface.
|
||||
|
||||
``agent_runtime_exit_intent`` is API-internal lifecycle policy for the
|
||||
Agent backend session after this turn finishes. Normal chat/resume turns
|
||||
suspend on exit; build-chat finalization deletes the backend runtime.
|
||||
|
||||
``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.
|
||||
@ -237,6 +243,7 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity):
|
||||
agent_config_snapshot_id: str
|
||||
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot"
|
||||
agent_runtime_session_snapshot_id: str | None = None
|
||||
agent_runtime_exit_intent: AgentRuntimeExitIntent = "suspend"
|
||||
prompt_file_mappings: Sequence[JsonValue] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@ -309,32 +309,20 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
response = self._message_cycle_manager.message_file_to_stream_response(event)
|
||||
if response:
|
||||
yield response
|
||||
case QueueLLMChunkEvent() | QueueAgentMessageEvent():
|
||||
case QueueAgentMessageEvent():
|
||||
chunk = event.chunk
|
||||
delta_content = chunk.delta.message.content
|
||||
if delta_content is None:
|
||||
delta_text = self._chunk_delta_text(chunk)
|
||||
if delta_text is None:
|
||||
continue
|
||||
yield self._agent_message_to_stream_response(
|
||||
answer=delta_text,
|
||||
message_id=self._message_id,
|
||||
)
|
||||
case QueueLLMChunkEvent():
|
||||
chunk = event.chunk
|
||||
delta_text = self._chunk_delta_text(chunk)
|
||||
if delta_text is None:
|
||||
continue
|
||||
if isinstance(delta_content, list):
|
||||
# EasyUI streams text only; structured multimodal chunks contribute their text parts.
|
||||
delta_text = ""
|
||||
for content in delta_content:
|
||||
logger.debug(
|
||||
"The content type %s in LLM chunk delta message content.: %r", type(content), content
|
||||
)
|
||||
match content:
|
||||
case TextPromptMessageContent():
|
||||
delta_text += content.data
|
||||
case str():
|
||||
delta_text += content # failback to str
|
||||
case _:
|
||||
logger.warning(
|
||||
"Unsupported content type %s in LLM chunk delta message content.: %r",
|
||||
type(content),
|
||||
content,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
delta_text = delta_content
|
||||
|
||||
if not self._task_state.llm_result.prompt_messages:
|
||||
self._task_state.llm_result.prompt_messages = chunk.prompt_messages
|
||||
@ -348,23 +336,16 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
current_content += delta_text
|
||||
self._task_state.llm_result.message.content = current_content
|
||||
|
||||
match event:
|
||||
case QueueLLMChunkEvent():
|
||||
# Determine the event type once, on first LLM chunk, and reuse for subsequent chunks
|
||||
if not hasattr(self, "_precomputed_event_type") or self._precomputed_event_type is None:
|
||||
self._precomputed_event_type = self._message_cycle_manager.get_message_event_type(
|
||||
message_id=self._message_id
|
||||
)
|
||||
yield self._message_cycle_manager.message_to_stream_response(
|
||||
answer=delta_text,
|
||||
message_id=self._message_id,
|
||||
event_type=self._precomputed_event_type,
|
||||
)
|
||||
case _:
|
||||
yield self._agent_message_to_stream_response(
|
||||
answer=delta_text,
|
||||
message_id=self._message_id,
|
||||
)
|
||||
# Determine the event type once, on first LLM chunk, and reuse for subsequent chunks
|
||||
if not hasattr(self, "_precomputed_event_type") or self._precomputed_event_type is None:
|
||||
self._precomputed_event_type = self._message_cycle_manager.get_message_event_type(
|
||||
message_id=self._message_id
|
||||
)
|
||||
yield self._message_cycle_manager.message_to_stream_response(
|
||||
answer=delta_text,
|
||||
message_id=self._message_id,
|
||||
event_type=self._precomputed_event_type,
|
||||
)
|
||||
case QueueMessageReplaceEvent():
|
||||
yield self._message_cycle_manager.message_replace_to_stream_response(answer=event.text)
|
||||
case QueuePingEvent():
|
||||
@ -376,6 +357,32 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
if self._conversation_name_generate_thread:
|
||||
logger.debug("Conversation name generation running as daemon thread")
|
||||
|
||||
@staticmethod
|
||||
def _chunk_delta_text(chunk: LLMResultChunk) -> str | None:
|
||||
delta_content = chunk.delta.message.content
|
||||
if delta_content is None:
|
||||
return None
|
||||
if not isinstance(delta_content, list):
|
||||
return delta_content
|
||||
|
||||
delta_text = ""
|
||||
# EasyUI streams text only; structured multimodal chunks contribute their text parts.
|
||||
for content in delta_content:
|
||||
logger.debug("The content type %s in LLM chunk delta message content.: %r", type(content), content)
|
||||
match content:
|
||||
case TextPromptMessageContent():
|
||||
delta_text += content.data
|
||||
case str():
|
||||
delta_text += content
|
||||
case _:
|
||||
logger.warning(
|
||||
"Unsupported content type %s in LLM chunk delta message content.: %r",
|
||||
type(content),
|
||||
content,
|
||||
)
|
||||
continue
|
||||
return delta_text
|
||||
|
||||
def _save_message(self, *, session: Session, trace_manager: TraceQueueManager | None = None):
|
||||
"""
|
||||
Save message.
|
||||
|
||||
@ -16,6 +16,7 @@ from clients.agent_backend import (
|
||||
AgentBackendRunEventAdapter,
|
||||
AgentBackendRunFailedInternalEvent,
|
||||
AgentBackendRunSucceededInternalEvent,
|
||||
AgentBackendSessionCleanupPayload,
|
||||
AgentBackendStreamError,
|
||||
AgentBackendStreamInternalEvent,
|
||||
AgentBackendTransportError,
|
||||
@ -34,6 +35,7 @@ from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEven
|
||||
from graphon.nodes.base.node import Node
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
|
||||
from services.agent.prompt_mentions import extract_workflow_node_output_selectors
|
||||
from tasks.agent_backend_session_cleanup_task import cleanup_workflow_agent_runtime_session
|
||||
|
||||
from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason
|
||||
from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
|
||||
@ -609,6 +611,44 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
) -> None:
|
||||
if self._session_store is None:
|
||||
return
|
||||
stored_session = self._session_store.load_active_session(session_scope)
|
||||
try:
|
||||
if stored_session is not None and stored_session.runtime_layer_specs:
|
||||
payload = AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||
idempotency_key=(
|
||||
f"{session_scope.tenant_id}:{session_scope.workflow_run_id}:{session_scope.node_id}:"
|
||||
f"{session_scope.binding_id}:workflow-agent-failure-cleanup:"
|
||||
f"{stored_session.backend_run_id or 'no-stored-run'}:{backend_run_id}"
|
||||
),
|
||||
metadata={
|
||||
"tenant_id": session_scope.tenant_id,
|
||||
"app_id": session_scope.app_id,
|
||||
"workflow_id": session_scope.workflow_id,
|
||||
"workflow_run_id": session_scope.workflow_run_id,
|
||||
"node_id": session_scope.node_id,
|
||||
"node_execution_id": session_scope.node_execution_id,
|
||||
"binding_id": session_scope.binding_id,
|
||||
"agent_id": session_scope.agent_id,
|
||||
"agent_config_snapshot_id": session_scope.agent_config_snapshot_id,
|
||||
"previous_agent_backend_run_id": stored_session.backend_run_id,
|
||||
"failed_agent_backend_run_id": backend_run_id,
|
||||
},
|
||||
)
|
||||
cleanup_workflow_agent_runtime_session.delay(payload.model_dump(mode="json"))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to enqueue workflow Agent backend cleanup on agent run failure: "
|
||||
"tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s backend_run_id=%s",
|
||||
session_scope.tenant_id,
|
||||
session_scope.workflow_run_id,
|
||||
session_scope.node_id,
|
||||
session_scope.binding_id,
|
||||
session_scope.agent_id,
|
||||
backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
self._session_store.mark_cleaned(scope=session_scope, backend_run_id=backend_run_id)
|
||||
agent_backend = dict(metadata.get("agent_backend") or {})
|
||||
|
||||
@ -454,7 +454,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
def _resolve_prompt_payload_value(cls, value: Any) -> tuple[Any, bool]:
|
||||
# File-valued workflow context must surface as Agent Stub download
|
||||
# mappings so the model can materialize those inputs with
|
||||
# `dify-agent file download --mapping ...` inside the sandbox.
|
||||
# `dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL` inside the sandbox.
|
||||
download_mapping = cls._agent_stub_download_mapping(value)
|
||||
if download_mapping is not None:
|
||||
return download_mapping, True
|
||||
@ -499,7 +499,21 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
return mapping.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
mapping = AgentStubFileMapping.model_validate(value)
|
||||
transfer_method = value.get("transfer_method")
|
||||
if not isinstance(transfer_method, str):
|
||||
return None
|
||||
if transfer_method == "remote_url":
|
||||
mapping = AgentStubFileMapping(
|
||||
transfer_method="remote_url",
|
||||
url=value.get("url") or value.get("remote_url"),
|
||||
)
|
||||
elif transfer_method in {"local_file", "tool_file", "datasource_file"}:
|
||||
mapping = AgentStubFileMapping(
|
||||
transfer_method=cast(AgentStubFileTransferMethod, transfer_method),
|
||||
reference=value.get("reference"),
|
||||
)
|
||||
else:
|
||||
return None
|
||||
return mapping.model_dump(mode="json", exclude_none=True)
|
||||
except ValidationError:
|
||||
return None
|
||||
@ -600,11 +614,12 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
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.
|
||||
narrower than the full CLI upload stdout: the sandbox must upload the
|
||||
local artifact via ``dify-agent file upload <path>``, then use the
|
||||
returned ``reference`` inside the accepted ``tool_file`` mapping shape
|
||||
for structured ``final_output``. ``local_file`` and ``datasource_file``
|
||||
are valid for existing file references in workflow context, not for
|
||||
newly produced Agent output files.
|
||||
"""
|
||||
return {
|
||||
"title": "AgentStubFileMapping",
|
||||
@ -659,9 +674,9 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
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`."
|
||||
f"then set `final_output.{output.name}` to a `tool_file` mapping using the returned "
|
||||
f"`reference`. 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
|
||||
@ -670,9 +685,9 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
):
|
||||
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."
|
||||
f"`final_output.{output.name}` to an array of `tool_file` mappings using the returned "
|
||||
f"`reference` values. 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
|
||||
@ -680,8 +695,12 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
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.",
|
||||
"Upload each sandbox-local file through the Agent Stub CLI first. For structured `final_output`, use "
|
||||
"only the accepted file-mapping shape and the returned `reference`; never invent the `reference` "
|
||||
"value.",
|
||||
"If you are replying to the user in natural language and want them to open or download the produced "
|
||||
"file, include the returned `download_url` in that reply instead of copying it into structured "
|
||||
"`final_output` unless the schema explicitly asks for it.",
|
||||
*file_output_lines,
|
||||
]
|
||||
)
|
||||
@ -779,6 +798,26 @@ def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBa
|
||||
|
||||
|
||||
def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> DifyKnowledgeRetrievalConfig:
|
||||
weights = None
|
||||
if retrieval.weights is not None:
|
||||
# The dify-agent runtime payload only consumes the nested vector/keyword
|
||||
# settings; ``weight_type`` is an API-side authoring detail and must not
|
||||
# leak into the inner request shape.
|
||||
weights = (
|
||||
cast(
|
||||
dict[str, Any],
|
||||
{
|
||||
key: value
|
||||
for key, value in {
|
||||
"vector_setting": retrieval.weights.vector_setting,
|
||||
"keyword_setting": retrieval.weights.keyword_setting,
|
||||
}.items()
|
||||
if value is not None
|
||||
},
|
||||
)
|
||||
or None
|
||||
)
|
||||
|
||||
return DifyKnowledgeRetrievalConfig(
|
||||
mode=retrieval.mode,
|
||||
top_k=retrieval.top_k,
|
||||
@ -791,9 +830,7 @@ def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> Dif
|
||||
)
|
||||
if retrieval.reranking_model is not None
|
||||
else None,
|
||||
weights=cast(dict[str, Any], retrieval.weights.model_dump(mode="json", exclude_none=True))
|
||||
if retrieval.weights is not None
|
||||
else None,
|
||||
weights=weights,
|
||||
model=_knowledge_model_config(retrieval.model),
|
||||
)
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
"""Workflow terminal layer that retires Agent backend sessions asynchronously."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from clients.agent_backend import AgentBackendError, AgentBackendRunClient, AgentBackendRunRequestBuilder
|
||||
from clients.agent_backend.factory import create_agent_backend_run_client
|
||||
from configs import dify_config
|
||||
from clients.agent_backend import AgentBackendSessionCleanupPayload
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
from graphon.graph_engine.layers import GraphEngineLayer
|
||||
from graphon.graph_events import (
|
||||
@ -15,57 +15,23 @@ from graphon.graph_events import (
|
||||
GraphRunPartialSucceededEvent,
|
||||
GraphRunSucceededEvent,
|
||||
)
|
||||
from tasks.agent_backend_session_cleanup_task import cleanup_workflow_agent_runtime_session
|
||||
|
||||
from .session_store import StoredWorkflowAgentSession, WorkflowAgentRuntimeSessionStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Upper bound on how long a cleanup-only run is allowed to settle before the
|
||||
# layer gives up and leaves the row ACTIVE so it can be retried later. Cleanup
|
||||
# work is mostly local agent-backend bookkeeping (no LLM inference), so 30s is
|
||||
# generous; a hung backend should never block workflow termination beyond this.
|
||||
_CLEANUP_WAIT_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
|
||||
"""Retires workflow Agent session snapshots when a workflow reaches a terminal state.
|
||||
"""Retire workflow-owned Agent runtime sessions when the workflow ends.
|
||||
|
||||
Implementation notes — there are two failure modes the cleanup path has to
|
||||
avoid simultaneously:
|
||||
|
||||
1. The agenton compositor on the agent-backend side validates the cleanup
|
||||
request's session snapshot against the replayed composition before
|
||||
running any lifecycle hook. If the snapshot's layer names diverge from
|
||||
the composition, the run fails asynchronously with ``run_failed`` — but
|
||||
the initial ``POST /runs`` already returned 202, so the API side has no
|
||||
visibility of the failure unless it waits for terminal status. The
|
||||
``runtime_layer_specs`` persistence in A.1–A.4 plus the
|
||||
``_filter_snapshot_to_specs`` shape in ``build_cleanup_request`` keeps
|
||||
the two name lists in sync.
|
||||
|
||||
2. The current agent backend's ``runner.py::_run_agent`` always invokes
|
||||
``run.get_layer("llm")`` and the structured-output / history validators
|
||||
before exiting any slot — there is no ``purpose: "cleanup"`` branch
|
||||
yet. A truly cleanup-only request (no LLM layer) therefore still
|
||||
crashes inside the runner with ``Layer 'llm' is not defined in this
|
||||
compositor run.``. Until the backend grows a cleanup-only purpose,
|
||||
this layer **does not issue an HTTP cleanup run**: it simply retires
|
||||
the local snapshot row so stale state cannot be re-resumed, and lets
|
||||
the agent backend's own retention TTL release the suspended layers.
|
||||
|
||||
The HTTP-cleanup machinery (``build_cleanup_request`` + ``wait_run``) is
|
||||
intentionally still wired into the request builder + integration tests so
|
||||
that when the agent backend supports cleanup runs we can flip the switch
|
||||
here with a one-line change (see ``_HTTP_CLEANUP_SUPPORTED``).
|
||||
Workflow termination is a product-lifecycle boundary: once the run reaches a
|
||||
terminal graph event, the local session row must no longer be resumable. The
|
||||
actual Agent backend cleanup is therefore dispatched asynchronously with the
|
||||
persisted snapshot/specs payload, while the local row is marked CLEANED
|
||||
immediately afterwards regardless of enqueue outcome.
|
||||
"""
|
||||
|
||||
# Flip to True once dify-agent's runner has a ``purpose=cleanup`` branch
|
||||
# that skips the LLM/output/user-prompt invariants. Until then we only
|
||||
# update the local row; the spec list is still persisted so the future
|
||||
# HTTP cleanup path has everything it needs.
|
||||
_HTTP_CLEANUP_SUPPORTED: bool = False
|
||||
|
||||
_TERMINAL_EVENTS = (
|
||||
GraphRunSucceededEvent,
|
||||
GraphRunPartialSucceededEvent,
|
||||
@ -73,19 +39,9 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
|
||||
GraphRunAbortedEvent,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_store: WorkflowAgentRuntimeSessionStore,
|
||||
request_builder: AgentBackendRunRequestBuilder,
|
||||
agent_backend_client: AgentBackendRunClient | None,
|
||||
cleanup_wait_timeout_seconds: float = _CLEANUP_WAIT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
def __init__(self, *, session_store: WorkflowAgentRuntimeSessionStore) -> None:
|
||||
super().__init__()
|
||||
self._session_store = session_store
|
||||
self._request_builder = request_builder
|
||||
self._agent_backend_client = agent_backend_client
|
||||
self._cleanup_wait_timeout_seconds = cleanup_wait_timeout_seconds
|
||||
|
||||
@override
|
||||
def on_graph_start(self) -> None:
|
||||
@ -112,136 +68,59 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
|
||||
|
||||
def _cleanup_session(self, stored_session: StoredWorkflowAgentSession) -> None:
|
||||
scope = stored_session.scope
|
||||
if not self._HTTP_CLEANUP_SUPPORTED:
|
||||
# Agent backend has no cleanup-only run mode yet (see class
|
||||
# docstring). Retire the local row so future re-entries do not
|
||||
# resume from stale state, and let the backend's retention TTL
|
||||
# release the suspended layers on its own schedule.
|
||||
logger.info(
|
||||
"Workflow Agent session retired locally; HTTP cleanup is disabled "
|
||||
"until the agent backend supports a cleanup-only run mode. "
|
||||
"workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s previous_run_id=%s",
|
||||
try:
|
||||
if stored_session.runtime_layer_specs:
|
||||
payload = AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||
idempotency_key=f"{scope.workflow_run_id}:{scope.node_id}:{scope.binding_id}:agent-session-cleanup",
|
||||
metadata={
|
||||
"tenant_id": scope.tenant_id,
|
||||
"app_id": scope.app_id,
|
||||
"workflow_id": scope.workflow_id,
|
||||
"workflow_run_id": scope.workflow_run_id,
|
||||
"node_id": scope.node_id,
|
||||
"node_execution_id": scope.node_execution_id,
|
||||
"binding_id": scope.binding_id,
|
||||
"agent_id": scope.agent_id,
|
||||
"agent_config_snapshot_id": scope.agent_config_snapshot_id,
|
||||
"previous_agent_backend_run_id": stored_session.backend_run_id,
|
||||
},
|
||||
)
|
||||
cleanup_workflow_agent_runtime_session.delay(payload.model_dump(mode="json"))
|
||||
else:
|
||||
logger.warning(
|
||||
"Skipping workflow Agent backend cleanup enqueue: no runtime_layer_specs persisted. "
|
||||
"workflow_run_id=%s node_id=%s agent_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.agent_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to enqueue workflow Agent backend cleanup: "
|
||||
"workflow_run_id=%s node_id=%s agent_id=%s previous_run_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.binding_id,
|
||||
scope.agent_id,
|
||||
stored_session.backend_run_id,
|
||||
)
|
||||
self._session_store.mark_cleaned(scope=scope, backend_run_id=stored_session.backend_run_id)
|
||||
return
|
||||
|
||||
if self._agent_backend_client is None:
|
||||
# HTTP cleanup was enabled by the caller but no client was wired
|
||||
# in (e.g. the API runs without AGENT_BACKEND_BASE_URL configured).
|
||||
# Leave the row ACTIVE so an operator restart with proper config
|
||||
# can drive the cleanup; do not silently retire it.
|
||||
logger.warning(
|
||||
"Skipping Agent backend cleanup: HTTP cleanup is enabled but no agent "
|
||||
"backend client is wired in. workflow_run_id=%s node_id=%s agent_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.agent_id,
|
||||
)
|
||||
return
|
||||
|
||||
if not stored_session.runtime_layer_specs:
|
||||
# Sessions persisted before A.1 landed do not carry the spec list,
|
||||
# so we cannot replay a valid cleanup composition. Leave the row
|
||||
# ACTIVE and warn so the absence shows up in observability rather
|
||||
# than being silently swallowed by a doomed cleanup run.
|
||||
logger.warning(
|
||||
"Skipping Agent backend cleanup: no runtime_layer_specs persisted. "
|
||||
"workflow_run_id=%s node_id=%s agent_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.agent_id,
|
||||
)
|
||||
return
|
||||
|
||||
request = self._request_builder.build_cleanup_request(
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||
idempotency_key=f"{scope.workflow_run_id}:{scope.node_id}:{scope.binding_id}:agent-session-cleanup",
|
||||
metadata={
|
||||
"tenant_id": scope.tenant_id,
|
||||
"app_id": scope.app_id,
|
||||
"workflow_id": scope.workflow_id,
|
||||
"workflow_run_id": scope.workflow_run_id,
|
||||
"node_id": scope.node_id,
|
||||
"node_execution_id": scope.node_execution_id,
|
||||
"binding_id": scope.binding_id,
|
||||
"agent_id": scope.agent_id,
|
||||
"agent_config_snapshot_id": scope.agent_config_snapshot_id,
|
||||
"previous_agent_backend_run_id": stored_session.backend_run_id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
response = self._agent_backend_client.create_run(request)
|
||||
except AgentBackendError:
|
||||
logger.warning(
|
||||
"Agent backend session cleanup request failed: workflow_run_id=%s node_id=%s agent_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.agent_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
status_response = self._agent_backend_client.wait_run(
|
||||
response.run_id, timeout_seconds=self._cleanup_wait_timeout_seconds
|
||||
)
|
||||
except AgentBackendError:
|
||||
logger.warning(
|
||||
"Agent backend session cleanup wait_run failed: "
|
||||
"workflow_run_id=%s node_id=%s agent_id=%s cleanup_run_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.agent_id,
|
||||
response.run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
if status_response.status != "succeeded":
|
||||
logger.warning(
|
||||
"Agent backend session cleanup did not succeed: status=%s error=%s "
|
||||
"workflow_run_id=%s node_id=%s agent_id=%s cleanup_run_id=%s",
|
||||
status_response.status,
|
||||
status_response.error,
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.agent_id,
|
||||
response.run_id,
|
||||
)
|
||||
return
|
||||
|
||||
self._session_store.mark_cleaned(scope=scope, backend_run_id=response.run_id)
|
||||
finally:
|
||||
try:
|
||||
self._session_store.mark_cleaned(scope=scope, backend_run_id=stored_session.backend_run_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to retire workflow Agent runtime session after cleanup enqueue: "
|
||||
"workflow_run_id=%s node_id=%s agent_id=%s previous_run_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
scope.agent_id,
|
||||
stored_session.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def build_workflow_agent_session_cleanup_layer() -> WorkflowAgentSessionCleanupLayer:
|
||||
"""Wire the cleanup layer with the standard production dependencies.
|
||||
|
||||
The agent backend client is constructed only when ``AGENT_BACKEND_BASE_URL``
|
||||
is configured (or the deterministic fake is explicitly enabled). When
|
||||
neither is set — for example unit tests that bring up the workflow runner
|
||||
without an Agent node — we pass ``None`` so the layer stays harmless. With
|
||||
``_HTTP_CLEANUP_SUPPORTED = False`` the local-retire branch never touches
|
||||
the client anyway, but keeping it ``None`` avoids importing httpx and lets
|
||||
test harnesses skip backend configuration.
|
||||
"""
|
||||
agent_backend_client: AgentBackendRunClient | None
|
||||
if dify_config.AGENT_BACKEND_USE_FAKE or dify_config.AGENT_BACKEND_BASE_URL:
|
||||
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,
|
||||
)
|
||||
else:
|
||||
agent_backend_client = None
|
||||
|
||||
return WorkflowAgentSessionCleanupLayer(
|
||||
session_store=WorkflowAgentRuntimeSessionStore(),
|
||||
request_builder=AgentBackendRunRequestBuilder(),
|
||||
agent_backend_client=agent_backend_client,
|
||||
)
|
||||
"""Wire the cleanup layer with the standard workflow-owned session store."""
|
||||
return WorkflowAgentSessionCleanupLayer(session_store=WorkflowAgentRuntimeSessionStore())
|
||||
|
||||
@ -110,6 +110,7 @@ class AgentThought(ResponseModel):
|
||||
message_id: str
|
||||
position: int
|
||||
thought: str | None = None
|
||||
answer: str | None = None
|
||||
tool: str | None = None
|
||||
tool_labels: JSONValue
|
||||
tool_input: str | None = None
|
||||
|
||||
@ -14626,6 +14626,7 @@ Legacy Chat App model config used only for follow-up question generation.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| answer | string | | No |
|
||||
| chain_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| files | [ string ] | | Yes |
|
||||
|
||||
@ -2335,6 +2335,7 @@ Retrieve the list of available models by type. Primarily used to query `text-emb
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| answer | string | | No |
|
||||
| chain_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| files | [ string ] | | Yes |
|
||||
|
||||
@ -936,6 +936,7 @@ Returns Server-Sent Events stream.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| answer | string | | No |
|
||||
| chain_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| files | [ string ] | | Yes |
|
||||
|
||||
@ -318,9 +318,10 @@ 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)"
|
||||
f"`dify-agent file upload <path>`, then set final_output.{output.name} to a `tool_file` mapping "
|
||||
f"using the returned `reference`; if replying to the user in natural language, use the returned "
|
||||
f"`download_url`; 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
|
||||
@ -329,9 +330,10 @@ def _format_output_mention(output: DeclaredOutputConfig) -> str:
|
||||
):
|
||||
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)"
|
||||
f"`dify-agent file upload <path>`, then set final_output.{output.name} to `tool_file` mappings "
|
||||
f"using the returned `reference` values; if replying to the user in natural language, use the returned "
|
||||
f"`download_url`; 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})"
|
||||
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import logging
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload
|
||||
from constants.model_template import default_app_templates
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import to_timestamp
|
||||
@ -38,6 +41,9 @@ from services.app_service import AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload
|
||||
from services.feature_service import FeatureService
|
||||
from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentReferencingWorkflow(TypedDict):
|
||||
@ -603,7 +609,15 @@ class AgentRosterService:
|
||||
def refresh_agent_app_debug_conversation_id(
|
||||
self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True
|
||||
) -> str:
|
||||
"""Start a new console debug conversation for the current Agent App editor."""
|
||||
"""Start a new console debug conversation for the current Agent App editor.
|
||||
|
||||
If this account already has a debug conversation mapping, the previous
|
||||
conversation is abandoned first: any ACTIVE conversation-owned Agent
|
||||
runtime sessions for that old conversation are sent through best-effort
|
||||
backend cleanup and then retired locally even when enqueueing fails.
|
||||
The debug mapping is then repointed to the freshly created
|
||||
conversation.
|
||||
"""
|
||||
|
||||
agent = self._session.scalar(
|
||||
select(Agent).where(
|
||||
@ -643,6 +657,16 @@ class AgentRosterService:
|
||||
)
|
||||
)
|
||||
else:
|
||||
previous_app_id = mapping.app_id
|
||||
previous_conversation_id = mapping.conversation_id
|
||||
if previous_conversation_id:
|
||||
self._cleanup_debug_conversation_runtime_sessions(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=account_id,
|
||||
app_id=previous_app_id or backing_app_id,
|
||||
conversation_id=previous_conversation_id,
|
||||
)
|
||||
mapping.app_id = backing_app_id
|
||||
mapping.conversation_id = conversation_id
|
||||
self._session.flush()
|
||||
@ -650,6 +674,84 @@ class AgentRosterService:
|
||||
self._session.commit()
|
||||
return conversation_id
|
||||
|
||||
def _cleanup_debug_conversation_runtime_sessions(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
app_id: str,
|
||||
conversation_id: str,
|
||||
) -> None:
|
||||
session_store = AgentAppRuntimeSessionStore()
|
||||
try:
|
||||
stored_sessions = session_store.list_active_sessions_for_conversation(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to load Agent App runtime sessions for debug conversation refresh: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s",
|
||||
tenant_id,
|
||||
app_id,
|
||||
conversation_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
for stored_session in stored_sessions:
|
||||
try:
|
||||
if stored_session.runtime_layer_specs:
|
||||
payload = AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||
idempotency_key=(
|
||||
f"{tenant_id}:{agent_id}:{account_id}:{conversation_id}:debug-session-cleanup:"
|
||||
f"{stored_session.scope.agent_id}:"
|
||||
f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:"
|
||||
f"{stored_session.backend_run_id or 'no-run'}"
|
||||
),
|
||||
metadata={
|
||||
"tenant_id": stored_session.scope.tenant_id,
|
||||
"app_id": stored_session.scope.app_id,
|
||||
"conversation_id": stored_session.scope.conversation_id,
|
||||
"agent_id": stored_session.scope.agent_id,
|
||||
"agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id,
|
||||
"previous_agent_backend_run_id": stored_session.backend_run_id,
|
||||
},
|
||||
)
|
||||
cleanup_conversation_agent_runtime_session.delay(payload.model_dump(mode="json"))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to enqueue Agent backend cleanup for debug conversation refresh: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||
stored_session.scope.tenant_id,
|
||||
stored_session.scope.app_id,
|
||||
stored_session.scope.conversation_id,
|
||||
stored_session.scope.agent_id,
|
||||
stored_session.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
session_store.mark_cleaned(
|
||||
scope=stored_session.scope,
|
||||
backend_run_id=stored_session.backend_run_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to retire Agent App runtime session for debug conversation refresh: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||
stored_session.scope.tenant_id,
|
||||
stored_session.scope.app_id,
|
||||
stored_session.scope.conversation_id,
|
||||
stored_session.scope.agent_id,
|
||||
stored_session.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def load_or_create_agent_app_debug_conversation_ids_by_agent_id(
|
||||
self, *, tenant_id: str, agents: list[Agent], account_id: str
|
||||
) -> dict[str, str]:
|
||||
|
||||
@ -40,35 +40,6 @@ 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]:
|
||||
"""
|
||||
|
||||
@ -6,7 +6,9 @@ from typing import Any
|
||||
from sqlalchemy import asc, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from clients.agent_backend import AgentBackendSessionCleanupPayload
|
||||
from configs import dify_config
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from factories import variable_factory
|
||||
@ -22,6 +24,7 @@ from services.errors.conversation import (
|
||||
LastConversationNotExistsError,
|
||||
)
|
||||
from services.errors.message import MessageNotExistsError
|
||||
from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session
|
||||
from tasks.delete_conversation_task import delete_conversation_related_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -186,10 +189,23 @@ class ConversationService:
|
||||
"""
|
||||
Delete a conversation only if it belongs to the given user and app context.
|
||||
|
||||
Before removing the conversation row, this best-effort lifecycle path
|
||||
enumerates any ACTIVE conversation-owned Agent backend runtime sessions,
|
||||
enqueues asynchronous backend cleanup for rows with persisted runtime
|
||||
layer specs, and then retires the local session rows even if enqueueing
|
||||
fails. Conversation deletion and related-data cleanup scheduling still
|
||||
proceed when that lifecycle bookkeeping only partially succeeds.
|
||||
|
||||
Raises:
|
||||
ConversationNotExistsError: When the conversation is not visible to the current user.
|
||||
"""
|
||||
conversation = cls.get_conversation(app_model, conversation_id, user, session=session)
|
||||
session_store = AgentAppRuntimeSessionStore()
|
||||
stored_sessions = session_store.list_active_sessions_for_conversation(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=conversation.id,
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
@ -197,15 +213,66 @@ class ConversationService:
|
||||
app_model.name,
|
||||
conversation_id,
|
||||
)
|
||||
for stored_session in stored_sessions:
|
||||
try:
|
||||
if stored_session.runtime_layer_specs:
|
||||
payload = AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||
idempotency_key=(
|
||||
f"{stored_session.scope.tenant_id}:{stored_session.scope.app_id}:"
|
||||
f"{stored_session.scope.conversation_id}:agent-runtime-session-cleanup:"
|
||||
f"{stored_session.scope.agent_id}:"
|
||||
f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:"
|
||||
f"{stored_session.backend_run_id or 'no-run'}"
|
||||
),
|
||||
metadata={
|
||||
"tenant_id": stored_session.scope.tenant_id,
|
||||
"app_id": stored_session.scope.app_id,
|
||||
"conversation_id": stored_session.scope.conversation_id,
|
||||
"agent_id": stored_session.scope.agent_id,
|
||||
"agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id,
|
||||
"previous_agent_backend_run_id": stored_session.backend_run_id,
|
||||
},
|
||||
)
|
||||
cleanup_conversation_agent_runtime_session.delay(payload.model_dump(mode="json"))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to enqueue Agent backend cleanup for conversation deletion: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||
stored_session.scope.tenant_id,
|
||||
stored_session.scope.app_id,
|
||||
stored_session.scope.conversation_id,
|
||||
stored_session.scope.agent_id,
|
||||
stored_session.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
session_store.mark_cleaned(
|
||||
scope=stored_session.scope,
|
||||
backend_run_id=stored_session.backend_run_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to retire Agent App runtime session for conversation deletion: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||
stored_session.scope.tenant_id,
|
||||
stored_session.scope.app_id,
|
||||
stored_session.scope.conversation_id,
|
||||
stored_session.scope.agent_id,
|
||||
stored_session.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
session.delete(conversation)
|
||||
session.commit()
|
||||
|
||||
delete_conversation_related_data.delay(conversation.id)
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise e
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def get_conversational_variable(
|
||||
|
||||
68
api/tasks/agent_backend_session_cleanup_task.py
Normal file
68
api/tasks/agent_backend_session_cleanup_task.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Celery tasks that execute Agent backend lifecycle-only session cleanup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from clients.agent_backend.factory import create_agent_backend_run_client
|
||||
from clients.agent_backend.request_builder import AgentBackendRunRequestBuilder
|
||||
from clients.agent_backend.session_cleanup import (
|
||||
AgentBackendSessionCleanupPayload,
|
||||
cleanup_agent_backend_session,
|
||||
)
|
||||
from configs import dify_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _create_agent_backend_client():
|
||||
if not (dify_config.AGENT_BACKEND_USE_FAKE or dify_config.AGENT_BACKEND_BASE_URL):
|
||||
return None
|
||||
return 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,
|
||||
)
|
||||
|
||||
|
||||
def _run_cleanup_task(payload_dict: dict[str, object]) -> None:
|
||||
payload = AgentBackendSessionCleanupPayload.model_validate(payload_dict)
|
||||
result = cleanup_agent_backend_session(
|
||||
payload=payload,
|
||||
client=_create_agent_backend_client(),
|
||||
request_builder=AgentBackendRunRequestBuilder(),
|
||||
)
|
||||
if result.status == "succeeded":
|
||||
return
|
||||
|
||||
log_fields = {
|
||||
"tenant_id": payload.metadata.get("tenant_id"),
|
||||
"app_id": payload.metadata.get("app_id"),
|
||||
"workflow_run_id": payload.metadata.get("workflow_run_id"),
|
||||
"node_id": payload.metadata.get("node_id"),
|
||||
"conversation_id": payload.metadata.get("conversation_id"),
|
||||
"agent_id": payload.metadata.get("agent_id"),
|
||||
"previous_agent_backend_run_id": payload.metadata.get("previous_agent_backend_run_id"),
|
||||
"failed_agent_backend_run_id": payload.metadata.get("failed_agent_backend_run_id"),
|
||||
"cleanup_run_id": result.cleanup_run_id,
|
||||
"reason": result.reason,
|
||||
}
|
||||
if result.status == "skipped":
|
||||
logger.info("Agent backend session cleanup skipped: %s", log_fields)
|
||||
return
|
||||
|
||||
logger.warning("Agent backend session cleanup failed: %s", log_fields)
|
||||
|
||||
|
||||
@shared_task(queue="workflow_storage")
|
||||
def cleanup_workflow_agent_runtime_session(payload_dict: dict[str, object]) -> None:
|
||||
"""Run one workflow-owned Agent backend cleanup payload."""
|
||||
_run_cleanup_task(payload_dict)
|
||||
|
||||
|
||||
@shared_task(queue="conversation")
|
||||
def cleanup_conversation_agent_runtime_session(payload_dict: dict[str, object]) -> None:
|
||||
"""Run one conversation-owned Agent backend cleanup payload."""
|
||||
_run_cleanup_task(payload_dict)
|
||||
@ -5,17 +5,25 @@ from typing import Any, cast
|
||||
|
||||
import click
|
||||
import sqlalchemy as sa
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from celery import shared_task
|
||||
from dify_agent.protocol import RuntimeLayerSpec
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from extensions.ext_database import db
|
||||
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models import (
|
||||
AgentRuntimeSession,
|
||||
AgentRuntimeSessionOwnerType,
|
||||
AgentRuntimeSessionStatus,
|
||||
ApiToken,
|
||||
AppAnnotationHitHistory,
|
||||
AppAnnotationSetting,
|
||||
@ -50,8 +58,13 @@ from models.workflow import (
|
||||
)
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from tasks.agent_backend_session_cleanup_task import (
|
||||
cleanup_conversation_agent_runtime_session,
|
||||
cleanup_workflow_agent_runtime_session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
|
||||
|
||||
|
||||
@shared_task(queue="app_deletion", bind=True, max_retries=3)
|
||||
@ -59,6 +72,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
|
||||
logger.info(click.style(f"Start deleting app and related data: {tenant_id}:{app_id}", fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
try:
|
||||
_cleanup_active_agent_runtime_sessions_for_app(tenant_id, app_id)
|
||||
# Delete related data
|
||||
_delete_app_model_configs(tenant_id, app_id)
|
||||
_delete_app_site(tenant_id, app_id)
|
||||
@ -99,6 +113,143 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
|
||||
raise self.retry(exc=e, countdown=60) # Retry after 60 seconds
|
||||
|
||||
|
||||
def _cleanup_active_agent_runtime_sessions_for_app(tenant_id: str, app_id: str, *, batch_size: int = 100) -> None:
|
||||
"""Best-effort fan-out for ACTIVE Agent runtime sessions during app deletion.
|
||||
|
||||
App deletion must not block on synchronous Agent backend lifecycle work, so
|
||||
this helper scans ACTIVE ``agent_runtime_sessions`` rows in batches,
|
||||
dispatches owner-specific cleanup tasks only when enough persisted data
|
||||
exists to replay a lifecycle-only run, and then marks each visited row
|
||||
``CLEANED`` locally regardless of enqueue outcome. The local retirement is
|
||||
the contract that lets the rest of app deletion continue even when backend
|
||||
cleanup dispatch is skipped or fails.
|
||||
"""
|
||||
if batch_size <= 0:
|
||||
raise ValueError("batch_size must be positive")
|
||||
|
||||
while True:
|
||||
with session_factory.create_session() as session:
|
||||
row_ids = session.scalars(
|
||||
select(AgentRuntimeSession.id)
|
||||
.where(
|
||||
AgentRuntimeSession.tenant_id == tenant_id,
|
||||
AgentRuntimeSession.app_id == app_id,
|
||||
AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
.order_by(AgentRuntimeSession.updated_at.asc())
|
||||
.limit(batch_size)
|
||||
).all()
|
||||
|
||||
if not row_ids:
|
||||
return
|
||||
|
||||
retired_count = 0
|
||||
for row_id in row_ids:
|
||||
with session_factory.create_session() as session:
|
||||
row = session.get(AgentRuntimeSession, row_id)
|
||||
if row is None or row.status != AgentRuntimeSessionStatus.ACTIVE:
|
||||
retired_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = _build_agent_runtime_session_cleanup_payload(row)
|
||||
if payload is not None:
|
||||
_enqueue_agent_runtime_session_cleanup(row=row, payload=payload)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to enqueue Agent backend cleanup during app deletion: "
|
||||
"tenant_id=%s app_id=%s owner_type=%s conversation_id=%s workflow_run_id=%s "
|
||||
"node_id=%s agent_id=%s backend_run_id=%s",
|
||||
row.tenant_id,
|
||||
row.app_id,
|
||||
row.owner_type,
|
||||
row.conversation_id,
|
||||
row.workflow_run_id,
|
||||
row.node_id,
|
||||
row.agent_id,
|
||||
row.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
row.status = AgentRuntimeSessionStatus.CLEANED
|
||||
row.cleaned_at = naive_utc_now()
|
||||
session.commit()
|
||||
retired_count += 1
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.warning(
|
||||
"Failed to retire Agent runtime session during app deletion: "
|
||||
"tenant_id=%s app_id=%s owner_type=%s conversation_id=%s workflow_run_id=%s "
|
||||
"node_id=%s agent_id=%s backend_run_id=%s",
|
||||
row.tenant_id,
|
||||
row.app_id,
|
||||
row.owner_type,
|
||||
row.conversation_id,
|
||||
row.workflow_run_id,
|
||||
row.node_id,
|
||||
row.agent_id,
|
||||
row.backend_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if retired_count == 0:
|
||||
logger.warning(
|
||||
"Failed to retire any active Agent runtime sessions during app deletion: tenant_id=%s app_id=%s",
|
||||
tenant_id,
|
||||
app_id,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def _build_agent_runtime_session_cleanup_payload(
|
||||
row: AgentRuntimeSession,
|
||||
) -> AgentBackendSessionCleanupPayload | None:
|
||||
runtime_layer_specs = _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(row.composition_layer_specs or "[]")
|
||||
if not runtime_layer_specs:
|
||||
return None
|
||||
|
||||
metadata: dict[str, JsonValue] = {
|
||||
"tenant_id": row.tenant_id,
|
||||
"app_id": row.app_id,
|
||||
"agent_id": row.agent_id,
|
||||
"agent_config_snapshot_id": row.agent_config_snapshot_id,
|
||||
"previous_agent_backend_run_id": row.backend_run_id,
|
||||
}
|
||||
if row.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION:
|
||||
metadata["conversation_id"] = row.conversation_id
|
||||
idempotency_key = (
|
||||
f"{row.tenant_id}:{row.app_id}:{row.conversation_id}:"
|
||||
f"{row.agent_id}:app-delete-cleanup:{row.id or row.backend_run_id or 'no-session-id'}"
|
||||
)
|
||||
else:
|
||||
metadata["workflow_run_id"] = row.workflow_run_id
|
||||
metadata["node_id"] = row.node_id
|
||||
idempotency_key = (
|
||||
f"{row.tenant_id}:{row.app_id}:{row.workflow_run_id}:{row.node_id}:"
|
||||
f"{row.agent_id}:app-delete-cleanup:{row.id or row.backend_run_id or 'no-session-id'}"
|
||||
)
|
||||
|
||||
return AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||
runtime_layer_specs=runtime_layer_specs,
|
||||
idempotency_key=idempotency_key,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _enqueue_agent_runtime_session_cleanup(
|
||||
*,
|
||||
row: AgentRuntimeSession,
|
||||
payload: AgentBackendSessionCleanupPayload,
|
||||
) -> None:
|
||||
payload_dict = payload.model_dump(mode="json")
|
||||
if row.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION:
|
||||
cleanup_conversation_agent_runtime_session.delay(payload_dict)
|
||||
return
|
||||
cleanup_workflow_agent_runtime_session.delay(payload_dict)
|
||||
|
||||
|
||||
def _delete_app_model_configs(tenant_id: str, app_id: str):
|
||||
def del_model_config(session, model_config_id: str):
|
||||
session.execute(
|
||||
|
||||
@ -6,11 +6,13 @@ from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models import TenantAccountRole
|
||||
from models import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus, TenantAccountRole
|
||||
from models.account import Account, Tenant, TenantAccountJoin
|
||||
from models.enums import ConversationFromSource, EndUserType
|
||||
from models.model import App, Conversation, EndUser, Message, MessageAnnotation
|
||||
@ -1081,8 +1083,9 @@ class TestConversationServiceExport:
|
||||
# Assert
|
||||
assert result == conversation
|
||||
|
||||
@patch("services.conversation_service.cleanup_conversation_agent_runtime_session")
|
||||
@patch("services.conversation_service.delete_conversation_related_data")
|
||||
def test_delete_conversation(self, mock_delete_task, db_session_with_containers: Session):
|
||||
def test_delete_conversation(self, mock_delete_task, mock_cleanup_task, db_session_with_containers: Session):
|
||||
"""
|
||||
Test conversation deletion with async cleanup.
|
||||
|
||||
@ -1101,6 +1104,20 @@ class TestConversationServiceExport:
|
||||
user,
|
||||
)
|
||||
conversation_id = conversation.id
|
||||
runtime_session = AgentRuntimeSession(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id=str(uuid4()),
|
||||
agent_config_snapshot_id=str(uuid4()),
|
||||
backend_run_id="backend-run-1",
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]).model_dump_json(),
|
||||
composition_layer_specs='[{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]',
|
||||
conversation_id=conversation.id,
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
db_session_with_containers.add(runtime_session)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
# Act - Delete the conversation
|
||||
ConversationService.delete(
|
||||
@ -1115,9 +1132,29 @@ class TestConversationServiceExport:
|
||||
# Step 2: Async cleanup task triggered
|
||||
# The Celery task will handle cleanup of messages, annotations, etc.
|
||||
mock_delete_task.delay.assert_called_once_with(conversation_id)
|
||||
mock_cleanup_task.delay.assert_called_once()
|
||||
cleanup_payload = mock_cleanup_task.delay.call_args.args[0]
|
||||
assert cleanup_payload["metadata"]["conversation_id"] == conversation_id
|
||||
assert (
|
||||
cleanup_payload["idempotency_key"]
|
||||
== f"{app_model.tenant_id}:{app_model.id}:{conversation_id}:agent-runtime-session-cleanup:"
|
||||
f"{runtime_session.agent_id}:{runtime_session.agent_config_snapshot_id}:{runtime_session.backend_run_id}"
|
||||
)
|
||||
|
||||
runtime_session_row = db_session_with_containers.scalar(
|
||||
select(AgentRuntimeSession).where(AgentRuntimeSession.id == runtime_session.id)
|
||||
)
|
||||
assert runtime_session_row is not None
|
||||
assert runtime_session_row.status == AgentRuntimeSessionStatus.CLEANED
|
||||
|
||||
@patch("services.conversation_service.cleanup_conversation_agent_runtime_session")
|
||||
@patch("services.conversation_service.delete_conversation_related_data")
|
||||
def test_delete_conversation_not_owned_by_account(self, mock_delete_task, db_session_with_containers: Session):
|
||||
def test_delete_conversation_not_owned_by_account(
|
||||
self,
|
||||
mock_delete_task,
|
||||
mock_cleanup_task,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
"""
|
||||
Test deletion is denied when conversation belongs to a different account.
|
||||
"""
|
||||
@ -1147,14 +1184,22 @@ class TestConversationServiceExport:
|
||||
not_deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation.id))
|
||||
assert not_deleted is not None
|
||||
mock_delete_task.delay.assert_not_called()
|
||||
mock_cleanup_task.delay.assert_not_called()
|
||||
|
||||
@patch("services.conversation_service.cleanup_conversation_agent_runtime_session")
|
||||
@patch("services.conversation_service.delete_conversation_related_data")
|
||||
def test_delete_handles_exception_and_rollback(self, mock_delete_task, db_session_with_containers: Session):
|
||||
def test_delete_handles_exception_and_rollback(
|
||||
self,
|
||||
mock_delete_task,
|
||||
mock_cleanup_task,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
"""
|
||||
Test that delete propagates exceptions and does not trigger the cleanup task.
|
||||
|
||||
When a DB error occurs during deletion, the service must rollback the
|
||||
transaction and re-raise the exception without scheduling async cleanup.
|
||||
When a DB error occurs during deletion, the conversation row stays in
|
||||
place, but any already-enqueued Agent backend cleanup remains a
|
||||
best-effort terminal lifecycle action.
|
||||
"""
|
||||
# Arrange
|
||||
app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account(
|
||||
@ -1164,6 +1209,20 @@ class TestConversationServiceExport:
|
||||
db_session_with_containers, app_model, user
|
||||
)
|
||||
conversation_id = conversation.id
|
||||
runtime_session = AgentRuntimeSession(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id=str(uuid4()),
|
||||
agent_config_snapshot_id=str(uuid4()),
|
||||
backend_run_id="backend-run-rollback",
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]).model_dump_json(),
|
||||
composition_layer_specs='[{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]',
|
||||
conversation_id=conversation.id,
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
db_session_with_containers.add(runtime_session)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
# Act — force an error during the delete to exercise the rollback path
|
||||
with patch.object(db_session_with_containers, "delete", side_effect=Exception("DB error")):
|
||||
@ -1175,9 +1234,111 @@ class TestConversationServiceExport:
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
|
||||
# Assert — async cleanup must NOT have been scheduled
|
||||
# Assert — related-data deletion is not scheduled, but the backend
|
||||
# cleanup task was already enqueued before the row delete failed.
|
||||
mock_delete_task.delay.assert_not_called()
|
||||
mock_cleanup_task.delay.assert_called_once()
|
||||
cleanup_payload = mock_cleanup_task.delay.call_args.args[0]
|
||||
assert (
|
||||
cleanup_payload["idempotency_key"]
|
||||
== f"{app_model.tenant_id}:{app_model.id}:{conversation_id}:agent-runtime-session-cleanup:"
|
||||
f"{runtime_session.agent_id}:{runtime_session.agent_config_snapshot_id}:{runtime_session.backend_run_id}"
|
||||
)
|
||||
|
||||
# Conversation is still present because the deletion was never committed
|
||||
still_there = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation_id))
|
||||
assert still_there is not None
|
||||
|
||||
@patch("services.conversation_service.cleanup_conversation_agent_runtime_session")
|
||||
@patch("services.conversation_service.delete_conversation_related_data")
|
||||
def test_delete_ignores_mark_cleaned_failure(
|
||||
self,
|
||||
mock_delete_task,
|
||||
mock_cleanup_task,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account(
|
||||
db_session_with_containers
|
||||
)
|
||||
conversation = ConversationServiceIntegrationTestDataFactory.create_conversation(
|
||||
db_session_with_containers,
|
||||
app_model,
|
||||
user,
|
||||
)
|
||||
runtime_session = AgentRuntimeSession(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id=str(uuid4()),
|
||||
agent_config_snapshot_id=str(uuid4()),
|
||||
backend_run_id="backend-run-cleanup-failure",
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]).model_dump_json(),
|
||||
composition_layer_specs='[{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]',
|
||||
conversation_id=conversation.id,
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
db_session_with_containers.add(runtime_session)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
with patch.object(AgentAppRuntimeSessionStore, "mark_cleaned", side_effect=RuntimeError("cleanup failed")):
|
||||
ConversationService.delete(
|
||||
app_model=app_model,
|
||||
conversation_id=conversation.id,
|
||||
user=user,
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
|
||||
deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation.id))
|
||||
assert deleted is None
|
||||
mock_delete_task.delay.assert_called_once_with(conversation.id)
|
||||
mock_cleanup_task.delay.assert_called_once()
|
||||
|
||||
@patch("services.conversation_service.cleanup_conversation_agent_runtime_session")
|
||||
@patch("services.conversation_service.delete_conversation_related_data")
|
||||
def test_delete_ignores_cleanup_enqueue_failure_and_still_retires_runtime_session(
|
||||
self,
|
||||
mock_delete_task,
|
||||
mock_cleanup_task,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account(
|
||||
db_session_with_containers
|
||||
)
|
||||
conversation = ConversationServiceIntegrationTestDataFactory.create_conversation(
|
||||
db_session_with_containers,
|
||||
app_model,
|
||||
user,
|
||||
)
|
||||
conversation_id = conversation.id
|
||||
runtime_session = AgentRuntimeSession(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id=str(uuid4()),
|
||||
agent_config_snapshot_id=str(uuid4()),
|
||||
backend_run_id="backend-run-enqueue-failure",
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]).model_dump_json(),
|
||||
composition_layer_specs='[{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]',
|
||||
conversation_id=conversation.id,
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
db_session_with_containers.add(runtime_session)
|
||||
db_session_with_containers.commit()
|
||||
mock_cleanup_task.delay.side_effect = RuntimeError("queue down")
|
||||
|
||||
ConversationService.delete(
|
||||
app_model=app_model,
|
||||
conversation_id=conversation_id,
|
||||
user=user,
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
|
||||
deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation_id))
|
||||
assert deleted is None
|
||||
mock_delete_task.delay.assert_called_once_with(conversation_id)
|
||||
mock_cleanup_task.delay.assert_called_once()
|
||||
runtime_session_row = db_session_with_containers.scalar(
|
||||
select(AgentRuntimeSession).where(AgentRuntimeSession.id == runtime_session.id)
|
||||
)
|
||||
assert runtime_session_row is not None
|
||||
assert runtime_session_row.status == AgentRuntimeSessionStatus.CLEANED
|
||||
|
||||
@ -15,6 +15,7 @@ from dify_agent.protocol import (
|
||||
from pydantic_ai.messages import FinalResultEvent
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendAgentMessageDeltaInternalEvent,
|
||||
AgentBackendDeferredToolCallInternalEvent,
|
||||
AgentBackendInternalEventType,
|
||||
AgentBackendRunCancelledInternalEvent,
|
||||
@ -54,6 +55,25 @@ def test_event_adapter_maps_pydantic_ai_stream_event():
|
||||
assert event.data["event_kind"] == "final_result"
|
||||
|
||||
|
||||
def test_event_adapter_maps_pydantic_ai_stream_event_agent_message_delta_annotation():
|
||||
adapted = AgentBackendRunEventAdapter().adapt(
|
||||
PydanticAIStreamRunEvent(
|
||||
id="2-0",
|
||||
run_id="run-1",
|
||||
data=FinalResultEvent(tool_name=None, tool_call_id=None),
|
||||
agent_message_delta="hello",
|
||||
)
|
||||
)
|
||||
|
||||
assert adapted == [
|
||||
AgentBackendAgentMessageDeltaInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
delta="hello",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_event_adapter_maps_run_succeeded_to_final_output():
|
||||
snapshot = CompositorSessionSnapshot(layers=[])
|
||||
adapted = AgentBackendRunEventAdapter().adapt(
|
||||
|
||||
@ -88,6 +88,7 @@ def _run_input() -> AgentBackendWorkflowNodeRunInput:
|
||||
|
||||
def test_request_builder_outputs_dify_agent_create_run_request():
|
||||
request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input())
|
||||
dumped = request.model_dump(mode="json")
|
||||
|
||||
assert isinstance(request, CreateRunRequest)
|
||||
assert [layer.name for layer in request.composition.layers] == [
|
||||
@ -102,6 +103,7 @@ def test_request_builder_outputs_dify_agent_create_run_request():
|
||||
assert request.on_exit.default is ExitIntent.SUSPEND
|
||||
assert request.idempotency_key == "workflow-run-1:node-execution-1"
|
||||
assert request.metadata == {"workflow_id": "workflow-1", "node_id": "node-1"}
|
||||
assert "purpose" not in dumped
|
||||
|
||||
|
||||
def test_request_builder_separates_agent_soul_and_workflow_job_prompt():
|
||||
@ -121,6 +123,59 @@ def test_request_builder_separates_agent_soul_and_workflow_job_prompt():
|
||||
assert dumped["composition"]["layers"][2]["config"]["user"] == "Summarize the report."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent_config_version_kind", ["snapshot", "draft"])
|
||||
def test_agent_app_request_builder_keeps_agent_soul_prompt_for_snapshot_and_draft(
|
||||
agent_config_version_kind: str,
|
||||
):
|
||||
original_prompt = " You are Iris. \n"
|
||||
run_input = _agent_app_input().model_copy(
|
||||
update={
|
||||
"agent_config_version_kind": agent_config_version_kind,
|
||||
"agent_soul_prompt": original_prompt,
|
||||
}
|
||||
)
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
|
||||
prompt_config = cast(PromptLayerConfig, layers[AGENT_SOUL_PROMPT_LAYER_ID].config)
|
||||
assert prompt_config.prefix == original_prompt
|
||||
|
||||
|
||||
def test_agent_app_request_builder_wraps_agent_soul_prompt_for_build_draft():
|
||||
original_prompt = " You are Iris. \n"
|
||||
run_input = _agent_app_input().model_copy(
|
||||
update={
|
||||
"agent_config_version_kind": "build_draft",
|
||||
"agent_soul_prompt": original_prompt,
|
||||
}
|
||||
)
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
|
||||
prompt_config = cast(PromptLayerConfig, layers[AGENT_SOUL_PROMPT_LAYER_ID].config)
|
||||
assert prompt_config.prefix != original_prompt
|
||||
assert prompt_config.prefix.startswith("You are running in build mode.")
|
||||
assert "```text\nYou are Iris.\n```" in prompt_config.prefix
|
||||
|
||||
|
||||
def test_agent_app_request_builder_uses_longer_fence_for_build_draft_prompt_body():
|
||||
run_input = _agent_app_input().model_copy(
|
||||
update={
|
||||
"agent_config_version_kind": "build_draft",
|
||||
"agent_soul_prompt": "Keep this snippet:\n```python\nprint('hi')\n```",
|
||||
}
|
||||
)
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
|
||||
prompt_config = cast(PromptLayerConfig, layers[AGENT_SOUL_PROMPT_LAYER_ID].config)
|
||||
assert "````text" in prompt_config.prefix
|
||||
assert "```python" in prompt_config.prefix
|
||||
|
||||
|
||||
def test_request_builder_sets_model_and_output_layer_contract_ids():
|
||||
request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input())
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
@ -250,6 +305,7 @@ def test_request_builder_builds_cleanup_request_replays_persisted_layer_specs():
|
||||
assert request.on_exit.default is ExitIntent.DELETE
|
||||
assert request.idempotency_key == "run-1:node-1:binding-1:agent-session-cleanup"
|
||||
assert request.metadata["agent_backend_lifecycle"] == "session_cleanup"
|
||||
assert "purpose" not in request.model_dump(mode="json")
|
||||
|
||||
|
||||
def test_request_builder_rejects_empty_runtime_layer_specs():
|
||||
@ -392,6 +448,19 @@ def test_agent_app_request_builder_omits_shell_layer_by_default():
|
||||
assert DIFY_SHELL_LAYER_ID not in {layer.name for layer in request.composition.layers}
|
||||
|
||||
|
||||
def test_agent_app_request_builder_keeps_build_draft_prompt_when_agent_soul_prompt_is_blank():
|
||||
run_input = _agent_app_input().model_copy(
|
||||
update={"agent_soul_prompt": " ", "agent_config_version_kind": "build_draft"}
|
||||
)
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
|
||||
prompt_config = cast(PromptLayerConfig, layers[AGENT_SOUL_PROMPT_LAYER_ID].config)
|
||||
assert "You are running in build mode." in prompt_config.prefix
|
||||
assert "No task prompt was provided." in prompt_config.prefix
|
||||
|
||||
|
||||
def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
|
||||
run_input = _agent_app_input(include_shell=True)
|
||||
run_input.shell_config = DifyShellLayerConfig(env=[DifyShellEnvVarConfig(name="APP_ENV", value="enabled")])
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||
from agenton.layers.base import LifecycleState
|
||||
from dify_agent.protocol import RunStatusResponse
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendError,
|
||||
AgentBackendSessionCleanupPayload,
|
||||
FakeAgentBackendRunClient,
|
||||
RuntimeLayerSpec,
|
||||
cleanup_agent_backend_session,
|
||||
)
|
||||
|
||||
|
||||
def _payload() -> AgentBackendSessionCleanupPayload:
|
||||
return AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=CompositorSessionSnapshot(
|
||||
layers=[
|
||||
LayerSessionSnapshot(
|
||||
name="history",
|
||||
lifecycle_state=LifecycleState.SUSPENDED,
|
||||
runtime_state={},
|
||||
)
|
||||
]
|
||||
),
|
||||
runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")],
|
||||
idempotency_key="cleanup-1",
|
||||
metadata={"tenant_id": "tenant-1"},
|
||||
timeout_seconds=15.0,
|
||||
)
|
||||
|
||||
|
||||
def test_cleanup_agent_backend_session_runs_create_and_wait_until_success():
|
||||
client = FakeAgentBackendRunClient(run_id="cleanup-run-1")
|
||||
|
||||
result = cleanup_agent_backend_session(payload=_payload(), client=client)
|
||||
|
||||
assert result.status == "succeeded"
|
||||
assert result.cleanup_run_id == "cleanup-run-1"
|
||||
assert client.request is not None
|
||||
assert [layer.name for layer in client.request.composition.layers] == ["history"]
|
||||
|
||||
|
||||
def test_cleanup_agent_backend_session_skips_when_client_is_missing():
|
||||
result = cleanup_agent_backend_session(
|
||||
payload=_payload(),
|
||||
client=None,
|
||||
)
|
||||
|
||||
assert result.status == "skipped"
|
||||
assert result.reason == "no_agent_backend_client"
|
||||
|
||||
|
||||
def test_cleanup_agent_backend_session_skips_when_session_snapshot_is_missing():
|
||||
payload = _payload().model_copy(update={"session_snapshot": None})
|
||||
|
||||
result = cleanup_agent_backend_session(payload=payload, client=FakeAgentBackendRunClient())
|
||||
|
||||
assert result.status == "skipped"
|
||||
assert result.reason == "missing_session_snapshot"
|
||||
|
||||
|
||||
def test_cleanup_agent_backend_session_skips_when_runtime_layer_specs_are_missing():
|
||||
payload = _payload().model_copy(update={"runtime_layer_specs": []})
|
||||
|
||||
result = cleanup_agent_backend_session(payload=payload, client=FakeAgentBackendRunClient())
|
||||
|
||||
assert result.status == "skipped"
|
||||
assert result.reason == "missing_runtime_layer_specs"
|
||||
|
||||
|
||||
class _FailedStatusClient(FakeAgentBackendRunClient):
|
||||
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
||||
del timeout_seconds
|
||||
return RunStatusResponse(
|
||||
run_id=run_id,
|
||||
status="failed",
|
||||
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
error="snapshot mismatch",
|
||||
)
|
||||
|
||||
|
||||
def test_cleanup_agent_backend_session_reports_failed_terminal_status():
|
||||
client = _FailedStatusClient(run_id="cleanup-run-2")
|
||||
|
||||
result = cleanup_agent_backend_session(payload=_payload(), client=client)
|
||||
|
||||
assert result.status == "failed"
|
||||
assert result.reason == "snapshot mismatch"
|
||||
assert result.cleanup_run_id == "cleanup-run-2"
|
||||
|
||||
|
||||
class _CreateRunFailureClient(FakeAgentBackendRunClient):
|
||||
def create_run(self, request): # type: ignore[override]
|
||||
del request
|
||||
raise AgentBackendError("create run failed")
|
||||
|
||||
|
||||
class _WaitRunFailureClient(FakeAgentBackendRunClient):
|
||||
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
||||
del run_id, timeout_seconds
|
||||
raise AgentBackendError("wait run failed")
|
||||
|
||||
|
||||
def test_cleanup_agent_backend_session_returns_failed_when_create_run_raises():
|
||||
result = cleanup_agent_backend_session(payload=_payload(), client=_CreateRunFailureClient())
|
||||
|
||||
assert result.status == "failed"
|
||||
assert result.reason == "create run failed"
|
||||
assert result.cleanup_run_id is None
|
||||
|
||||
|
||||
def test_cleanup_agent_backend_session_returns_failed_with_cleanup_run_id_when_wait_run_raises():
|
||||
result = cleanup_agent_backend_session(
|
||||
payload=_payload(),
|
||||
client=_WaitRunFailureClient(run_id="cleanup-run-3"),
|
||||
)
|
||||
|
||||
assert result.status == "failed"
|
||||
assert result.reason == "wait run failed"
|
||||
assert result.cleanup_run_id == "cleanup-run-3"
|
||||
@ -1576,6 +1576,7 @@ def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt(
|
||||
assert args["conversation_id"] == "debug-conversation-1"
|
||||
assert args["inputs"] == {}
|
||||
assert args["auto_generate_name"] is False
|
||||
assert args[completion_controller.AGENT_RUNTIME_EXIT_INTENT_ARG] == "delete"
|
||||
assert args["external_trace_id"] == "trace-1"
|
||||
|
||||
|
||||
@ -1664,6 +1665,51 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace(
|
||||
assert args["external_trace_id"] == "trace-1"
|
||||
|
||||
|
||||
def test_agent_chat_helper_ignores_private_exit_intent_payload_key(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
||||
) -> None:
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent")
|
||||
current_user = SimpleNamespace(id=account_id)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def generate(**kwargs: object) -> dict[str, object]:
|
||||
captured.update(kwargs)
|
||||
return {"answer": "ok"}
|
||||
|
||||
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
|
||||
monkeypatch.setattr(
|
||||
completion_controller,
|
||||
"_resolve_current_user_agent_debug_conversation_id",
|
||||
lambda **kwargs: "debug-conversation-1",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
completion_controller.helper,
|
||||
"compact_generate_response",
|
||||
lambda response: {"response": response},
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
json={
|
||||
"inputs": {},
|
||||
"query": "hello",
|
||||
"response_mode": "streaming",
|
||||
completion_controller.AGENT_RUNTIME_EXIT_INTENT_ARG: "delete",
|
||||
}
|
||||
):
|
||||
result = completion_controller._create_chat_message(
|
||||
current_user=current_user,
|
||||
app_model=app_model,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
assert result == {"response": {"answer": "ok"}}
|
||||
assert captured["streaming"] is True
|
||||
args = cast(dict[str, object], captured["args"])
|
||||
assert args["response_mode"] == "streaming"
|
||||
assert args["conversation_id"] == "debug-conversation-1"
|
||||
assert completion_controller.AGENT_RUNTIME_EXIT_INTENT_ARG not in args
|
||||
|
||||
|
||||
def test_agent_chat_helper_rejects_foreign_debug_conversation(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
@ -21,7 +20,8 @@ from core.app.apps.agent_app.app_generator import (
|
||||
AgentAppGeneratorError,
|
||||
)
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.entities.app_invoke_entities import AGENT_RUNTIME_EXIT_INTENT_ARG, InvokeFrom, UserFrom
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from models import Account
|
||||
from models.agent import AgentConfigDraftType
|
||||
|
||||
@ -135,6 +135,79 @@ class TestGenerateSuccess:
|
||||
user=user,
|
||||
)
|
||||
assert generate_entity.call_args.kwargs["prompt_file_mappings"] == file_mappings
|
||||
assert generate_entity.call_args.kwargs["agent_runtime_exit_intent"] == "suspend"
|
||||
|
||||
def test_generate_uses_delete_exit_intent_from_internal_arg(self, generator, 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"), "snap1", "snapshot", mocker.MagicMock())
|
||||
)
|
||||
generator._prepare_user_inputs = mocker.MagicMock(return_value={})
|
||||
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="raw-response")
|
||||
|
||||
mocker.patch(
|
||||
f"{MODULE}.AgentAppConfigManager.get_app_config",
|
||||
return_value=mocker.MagicMock(variables=[], tenant_id="tenant", app_id="app1"),
|
||||
)
|
||||
mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock(model="gpt-4o-mini"))
|
||||
mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock())
|
||||
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())
|
||||
mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert", return_value={"result": "ok"})
|
||||
|
||||
generator.generate(
|
||||
app_model=app_model,
|
||||
user=user,
|
||||
args={"query": "hello", "inputs": {}, AGENT_RUNTIME_EXIT_INTENT_ARG: "delete"},
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
assert generate_entity.call_args.kwargs["agent_runtime_exit_intent"] == "delete"
|
||||
|
||||
def test_generate_falls_back_to_suspend_for_invalid_internal_exit_intent(self, generator, 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"), "snap1", "snapshot", mocker.MagicMock())
|
||||
)
|
||||
generator._prepare_user_inputs = mocker.MagicMock(return_value={})
|
||||
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="raw-response")
|
||||
|
||||
mocker.patch(
|
||||
f"{MODULE}.AgentAppConfigManager.get_app_config",
|
||||
return_value=mocker.MagicMock(variables=[], tenant_id="tenant", app_id="app1"),
|
||||
)
|
||||
mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock(model="gpt-4o-mini"))
|
||||
mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock())
|
||||
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())
|
||||
mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock())
|
||||
mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert", return_value={"result": "ok"})
|
||||
|
||||
generator.generate(
|
||||
app_model=app_model,
|
||||
user=user,
|
||||
args={"query": "hello", "inputs": {}, AGENT_RUNTIME_EXIT_INTENT_ARG: "bogus"},
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
assert generate_entity.call_args.kwargs["agent_runtime_exit_intent"] == "suspend"
|
||||
|
||||
def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture):
|
||||
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
|
||||
@ -205,81 +278,6 @@ 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)
|
||||
@ -329,6 +327,7 @@ class TestGenerateWorker:
|
||||
query="query",
|
||||
runtime_session_snapshot_id="s",
|
||||
prompt_file_mappings=(),
|
||||
agent_runtime_exit_intent="suspend",
|
||||
):
|
||||
generator._generate_worker(
|
||||
flask_app=mocker.MagicMock(),
|
||||
@ -337,6 +336,7 @@ class TestGenerateWorker:
|
||||
agent_id="a",
|
||||
agent_config_snapshot_id="s",
|
||||
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
|
||||
agent_runtime_exit_intent=agent_runtime_exit_intent,
|
||||
model_conf=mocker.MagicMock(model="m"),
|
||||
query=query,
|
||||
prompt_file_mappings=prompt_file_mappings,
|
||||
@ -364,6 +364,14 @@ 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_forwards_runtime_exit_intent_to_runner(self, generator, mocker: MockerFixture):
|
||||
runner = self._wire(generator, mocker)
|
||||
queue_manager = mocker.MagicMock()
|
||||
|
||||
self._call(generator, mocker, queue_manager, agent_runtime_exit_intent="delete")
|
||||
|
||||
assert runner.run.call_args.kwargs["agent_runtime_exit_intent"] == "delete"
|
||||
|
||||
def test_worker_appends_prompt_files_to_backend_query(self, generator, mocker: MockerFixture):
|
||||
runner = self._wire(generator, mocker, guard_query="你看得见这张图片吗")
|
||||
queue_manager = mocker.MagicMock()
|
||||
@ -373,7 +381,23 @@ class TestGenerateWorker:
|
||||
"transfer_method": "local_file",
|
||||
"url": "",
|
||||
"upload_file_id": "upload-file-1",
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "document",
|
||||
"transfer_method": "remote_url",
|
||||
"url": "https://example.com/source.pdf",
|
||||
"upload_file_id": "ignored",
|
||||
},
|
||||
]
|
||||
expected_file_mappings = [
|
||||
{
|
||||
"transfer_method": "local_file",
|
||||
"reference": build_file_reference(record_id="upload-file-1"),
|
||||
},
|
||||
{
|
||||
"transfer_method": "remote_url",
|
||||
"url": "https://example.com/source.pdf",
|
||||
},
|
||||
]
|
||||
|
||||
self._call(
|
||||
@ -385,7 +409,10 @@ class TestGenerateWorker:
|
||||
)
|
||||
|
||||
assert runner.run.call_args.kwargs["query"] == (
|
||||
f"你看得见这张图片吗\n{json.dumps(file_mappings, ensure_ascii=False)}"
|
||||
"你看得见这张图片吗\nUser provided files: "
|
||||
"use dify-agent file download with the listed transfer_method and reference/url "
|
||||
"to get the files and investigate them\n"
|
||||
f"{json.dumps(expected_file_mappings, ensure_ascii=False, separators=(',', ':'))}"
|
||||
)
|
||||
|
||||
def test_input_guard_short_circuit_skips_backend(self, generator, mocker: MockerFixture):
|
||||
@ -461,6 +488,7 @@ class TestResumeAfterFormSubmission:
|
||||
|
||||
# The paused turn's query is re-sent verbatim — never blank.
|
||||
assert entity.call_args.kwargs["query"] == "original question"
|
||||
assert "agent_runtime_exit_intent" not in entity.call_args.kwargs
|
||||
|
||||
def test_resume_falls_back_to_placeholder_when_no_paused_message(self, generator, mocker: MockerFixture):
|
||||
entity = self._wire(generator, mocker)
|
||||
|
||||
@ -28,8 +28,6 @@ from pydantic_ai.messages import (
|
||||
FunctionToolCallEvent,
|
||||
FunctionToolResultEvent,
|
||||
PartDeltaEvent,
|
||||
PartStartEvent,
|
||||
TextPart,
|
||||
TextPartDelta,
|
||||
ThinkingPartDelta,
|
||||
ToolCallPart,
|
||||
@ -49,7 +47,12 @@ from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeReque
|
||||
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 QueueAgentThoughtEvent, QueueLLMChunkEvent, QueueMessageEndEvent
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueAgentMessageEvent,
|
||||
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
|
||||
@ -98,24 +101,6 @@ 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]:
|
||||
@ -127,12 +112,14 @@ class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")),
|
||||
agent_message_delta="hello ",
|
||||
)
|
||||
yield PydanticAIStreamRunEvent(
|
||||
id="3-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")),
|
||||
agent_message_delta="agent",
|
||||
)
|
||||
yield RunSucceededEvent(
|
||||
id="4-0",
|
||||
@ -157,12 +144,14 @@ class _StreamingRecordingFakeAgentBackendRunClient(_RecordingFakeAgentBackendRun
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")),
|
||||
agent_message_delta="hello ",
|
||||
)
|
||||
yield PydanticAIStreamRunEvent(
|
||||
id="3-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")),
|
||||
agent_message_delta="agent",
|
||||
)
|
||||
yield RunSucceededEvent(
|
||||
id="4-0",
|
||||
@ -190,6 +179,7 @@ class _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(_RecordingFakeAgent
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")),
|
||||
agent_message_delta="hello ",
|
||||
)
|
||||
self._queue_manager.request_stop()
|
||||
yield PydanticAIStreamRunEvent(
|
||||
@ -197,10 +187,11 @@ class _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(_RecordingFakeAgent
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")),
|
||||
agent_message_delta="agent",
|
||||
)
|
||||
|
||||
|
||||
class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
class _StreamingSingleAgentMessageDeltaFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
@override
|
||||
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
|
||||
del after
|
||||
@ -210,7 +201,8 @@ class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartStartEvent(index=0, part=TextPart(content="hello")),
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello")),
|
||||
agent_message_delta="hello",
|
||||
)
|
||||
yield RunSucceededEvent(
|
||||
id="3-0",
|
||||
@ -251,6 +243,7 @@ class _StreamingTextNullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClien
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="streamed answer")),
|
||||
agent_message_delta="streamed answer",
|
||||
)
|
||||
yield RunSucceededEvent(
|
||||
id="3-0",
|
||||
@ -263,6 +256,37 @@ class _StreamingTextNullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClien
|
||||
)
|
||||
|
||||
|
||||
class _AgentAnswerStreamingFakeAgentBackendRunClient(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=TextPartDelta(content_delta="hello ")),
|
||||
agent_message_delta="hello ",
|
||||
)
|
||||
yield PydanticAIStreamRunEvent(
|
||||
id="3-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")),
|
||||
agent_message_delta="agent",
|
||||
)
|
||||
yield RunSucceededEvent(
|
||||
id="4-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=RunSucceededEventData(
|
||||
output={"text": "final answer"},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _ProcessStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
@override
|
||||
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
|
||||
@ -292,6 +316,7 @@ class _ProcessStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=1, delta=TextPartDelta(content_delta="final answer")),
|
||||
agent_message_delta="final answer",
|
||||
)
|
||||
yield RunSucceededEvent(
|
||||
id="6-0",
|
||||
@ -318,6 +343,9 @@ class _FakeDbSession:
|
||||
def get(self, _model: type[MessageAgentThought], row_id: str) -> MessageAgentThought | None:
|
||||
return self.rows.get(row_id)
|
||||
|
||||
def delete(self, row: MessageAgentThought) -> None:
|
||||
self.rows.pop(str(row.id), None)
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rollback_count += 1
|
||||
|
||||
@ -327,9 +355,11 @@ class _FakeSessionStore:
|
||||
self,
|
||||
loaded: CompositorSessionSnapshot | None = None,
|
||||
loaded_session: StoredAgentAppSession | None = None,
|
||||
listed_sessions: list[StoredAgentAppSession] | None = None,
|
||||
) -> None:
|
||||
self.loaded = loaded
|
||||
self._loaded_session = loaded_session
|
||||
self._listed_sessions = list(listed_sessions or [])
|
||||
self.loaded_scopes: list[AgentAppSessionScope] = []
|
||||
self.saved: list[
|
||||
tuple[
|
||||
@ -341,6 +371,7 @@ class _FakeSessionStore:
|
||||
str | None,
|
||||
]
|
||||
] = []
|
||||
self.cleaned: list[tuple[AgentAppSessionScope, str | None]] = []
|
||||
|
||||
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
|
||||
self.loaded_scopes.append(scope)
|
||||
@ -354,6 +385,14 @@ class _FakeSessionStore:
|
||||
return None
|
||||
return StoredAgentAppSession(scope=scope, session_snapshot=self.loaded, backend_run_id=None)
|
||||
|
||||
def list_active_sessions_for_conversation(
|
||||
self, *, tenant_id: str, app_id: str, conversation_id: str
|
||||
) -> list[StoredAgentAppSession]:
|
||||
assert tenant_id == "tenant-1"
|
||||
assert app_id == "app-1"
|
||||
assert conversation_id == "conv-1"
|
||||
return list(self._listed_sessions)
|
||||
|
||||
def save_active_snapshot(
|
||||
self,
|
||||
*,
|
||||
@ -368,6 +407,9 @@ class _FakeSessionStore:
|
||||
(scope, backend_run_id, snapshot, list(runtime_layer_specs), pending_form_id, pending_tool_call_id)
|
||||
)
|
||||
|
||||
def mark_cleaned(self, *, scope: AgentAppSessionScope, backend_run_id: str | None = None) -> None:
|
||||
self.cleaned.append((scope, backend_run_id))
|
||||
|
||||
|
||||
class _MonotonicClock:
|
||||
def __init__(self, *values: float) -> None:
|
||||
@ -409,7 +451,7 @@ def _runner(
|
||||
client: FakeAgentBackendRunClient,
|
||||
store: _FakeSessionStore,
|
||||
*,
|
||||
text_delta_debounce_seconds: float | None = 0,
|
||||
text_delta_debounce_seconds: float = 0,
|
||||
) -> AgentAppRunner:
|
||||
return AgentAppRunner(
|
||||
request_builder=AgentAppRuntimeRequestBuilder(
|
||||
@ -423,7 +465,7 @@ def _runner(
|
||||
)
|
||||
|
||||
|
||||
def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None:
|
||||
def _run(runner: AgentAppRunner, qm: _FakeQueueManager, *, agent_runtime_exit_intent: str = "suspend") -> None:
|
||||
runner.run(
|
||||
dify_context=_dify_ctx(),
|
||||
agent_id="agent-1",
|
||||
@ -434,18 +476,7 @@ def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None:
|
||||
message_id="msg-1",
|
||||
model_name="gpt-4o-mini",
|
||||
queue_manager=qm, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
agent_runtime_exit_intent=agent_runtime_exit_intent, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@ -470,6 +501,8 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session():
|
||||
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
assert client.request is not None
|
||||
assert client.request.on_exit.default.value == "suspend"
|
||||
# One LLM chunk + one message-end, carrying the backend's answer text.
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
@ -490,38 +523,169 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session():
|
||||
# A successful turn carries no ask_human pause correlation.
|
||||
assert pending_form_id is None
|
||||
assert pending_tool_call_id is None
|
||||
assert store.cleaned == []
|
||||
|
||||
|
||||
def test_successful_turn_forwards_agent_backend_stream_text_deltas_without_duplicate_terminal_chunk():
|
||||
def test_successful_turn_enqueues_cleanup_for_superseded_sessions_after_saving_snapshot(monkeypatch):
|
||||
superseded = StoredAgentAppSession(
|
||||
scope=AgentAppSessionScope(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
conversation_id="conv-1",
|
||||
agent_id="agent-2",
|
||||
agent_config_snapshot_id="snap-2",
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
backend_run_id="run-old",
|
||||
runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")],
|
||||
)
|
||||
current_scope_session = StoredAgentAppSession(
|
||||
scope=AgentAppSessionScope(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
conversation_id="conv-1",
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-1",
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
backend_run_id="run-current",
|
||||
runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")],
|
||||
)
|
||||
store = _FakeSessionStore(listed_sessions=[current_scope_session, superseded])
|
||||
client = FakeAgentBackendRunClient()
|
||||
qm = _FakeQueueManager()
|
||||
cleanup_delay = MagicMock()
|
||||
monkeypatch.setattr(app_runner_module.cleanup_conversation_agent_runtime_session, "delay", cleanup_delay)
|
||||
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
assert store.saved
|
||||
cleanup_delay.assert_called_once()
|
||||
payload = cleanup_delay.call_args.args[0]
|
||||
assert payload["metadata"]["conversation_id"] == "conv-1"
|
||||
assert payload["metadata"]["agent_id"] == "agent-2"
|
||||
assert payload["metadata"]["previous_agent_backend_run_id"] == "run-old"
|
||||
assert payload["idempotency_key"] == "tenant-1:app-1:conv-1:agent-2:snap-2:superseded-session-cleanup:run-old"
|
||||
|
||||
|
||||
def test_superseded_session_cleanup_enqueue_failure_does_not_fail_turn(monkeypatch):
|
||||
superseded = StoredAgentAppSession(
|
||||
scope=AgentAppSessionScope(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
conversation_id="conv-1",
|
||||
agent_id="agent-2",
|
||||
agent_config_snapshot_id="snap-2",
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
backend_run_id="run-old",
|
||||
runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")],
|
||||
)
|
||||
store = _FakeSessionStore(listed_sessions=[superseded])
|
||||
client = FakeAgentBackendRunClient()
|
||||
qm = _FakeQueueManager()
|
||||
cleanup_delay = MagicMock(side_effect=RuntimeError("queue down"))
|
||||
monkeypatch.setattr(app_runner_module.cleanup_conversation_agent_runtime_session, "delay", cleanup_delay)
|
||||
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
cleanup_delay.assert_called_once()
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].llm_result.message.content == "hello agent"
|
||||
|
||||
|
||||
def test_delete_on_exit_turn_marks_session_cleaned_without_saving_snapshot():
|
||||
client = _StreamingRecordingFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store), qm, agent_runtime_exit_intent="delete")
|
||||
|
||||
assert client.request is not None
|
||||
assert client.request.on_exit.default.value == "delete"
|
||||
assert store.saved == []
|
||||
assert len(store.cleaned) == 1
|
||||
cleaned_scope, cleaned_run_id = store.cleaned[0]
|
||||
assert cleaned_scope.conversation_id == "conv-1"
|
||||
assert cleaned_scope.agent_config_snapshot_id == "snap-1"
|
||||
assert cleaned_run_id == "fake-run-1"
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].llm_result.message.content == "hello agent"
|
||||
|
||||
|
||||
def test_delete_on_exit_turn_swallows_cleanup_failure_after_success():
|
||||
client = _StreamingRecordingFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
store.mark_cleaned = MagicMock(side_effect=RuntimeError("cleanup failed")) # type: ignore[method-assign]
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store), qm, agent_runtime_exit_intent="delete")
|
||||
|
||||
assert store.saved == []
|
||||
store.mark_cleaned.assert_called_once()
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert len(end_events) == 1
|
||||
|
||||
|
||||
def test_delete_on_exit_turn_marks_session_cleaned_when_publish_fails():
|
||||
client = _StreamingRecordingFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
store.mark_cleaned = MagicMock(side_effect=RuntimeError("cleanup failed")) # type: ignore[method-assign]
|
||||
qm = _FakeQueueManager()
|
||||
runner = _runner(client, store)
|
||||
runner._publish_terminal_answer = MagicMock(side_effect=RuntimeError("publish failed"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="publish failed"):
|
||||
_run(runner, qm, agent_runtime_exit_intent="delete")
|
||||
|
||||
assert store.saved == []
|
||||
store.mark_cleaned.assert_called_once()
|
||||
|
||||
|
||||
def test_successful_turn_routes_stream_text_to_agent_message_and_uses_terminal_output(monkeypatch):
|
||||
fake_session = _FakeDbSession()
|
||||
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
|
||||
client = _StreamingFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store, text_delta_debounce_seconds=0), qm)
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)]
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello ", "agent"]
|
||||
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello agent"]
|
||||
assert [event.chunk.delta.message.content for event in agent_message_events] == ["hello ", "agent"]
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].llm_result.message.content == "hello agent"
|
||||
assert end_events[0].llm_result.usage.prompt_tokens == 3
|
||||
assert end_events[0].llm_result.usage.completion_tokens == 5
|
||||
assert end_events[0].llm_result.usage.total_tokens == 8
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert rows == []
|
||||
assert store.saved
|
||||
|
||||
|
||||
def test_successful_turn_forwards_part_start_text_and_publishes_missing_terminal_suffix():
|
||||
client = _StreamingPartStartFakeAgentBackendRunClient()
|
||||
def test_successful_turn_routes_single_agent_message_delta(monkeypatch):
|
||||
fake_session = _FakeDbSession()
|
||||
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
|
||||
client = _StreamingSingleAgentMessageDeltaFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store, text_delta_debounce_seconds=0), qm)
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)]
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello", " agent"]
|
||||
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello agent"]
|
||||
assert [event.chunk.delta.message.content for event in agent_message_events] == ["hello"]
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].llm_result.message.content == "hello agent"
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_successful_turn_with_null_terminal_output_publishes_empty_answer_not_literal_null():
|
||||
@ -532,24 +696,77 @@ def test_successful_turn_with_null_terminal_output_publishes_empty_answer_not_li
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)]
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert chunk_events == []
|
||||
assert agent_message_events == []
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].llm_result.message.content == ""
|
||||
|
||||
|
||||
def test_successful_turn_with_streamed_text_and_null_terminal_output_keeps_streamed_answer():
|
||||
def test_successful_turn_with_stream_text_and_null_terminal_output_keeps_empty_message(monkeypatch):
|
||||
fake_session = _FakeDbSession()
|
||||
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
|
||||
client = _StreamingTextNullOutputFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store, text_delta_debounce_seconds=0), qm)
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)]
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert [event.chunk.delta.message.content for event in chunk_events] == ["streamed answer"]
|
||||
assert chunk_events == []
|
||||
assert [event.chunk.delta.message.content for event in agent_message_events] == ["streamed answer"]
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].llm_result.message.content == "streamed answer"
|
||||
assert end_events[0].llm_result.message.content == ""
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].answer == "streamed answer"
|
||||
|
||||
|
||||
def test_successful_turn_routes_agent_answer_to_agent_message(monkeypatch):
|
||||
fake_session = _FakeDbSession()
|
||||
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
|
||||
client = _AgentAnswerStreamingFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)]
|
||||
assert [event.chunk.delta.message.content for event in chunk_events] == ["final answer"]
|
||||
assert [event.chunk.delta.message.content for event in agent_message_events] == ["hello ", "agent"]
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].llm_result.message.content == "final answer"
|
||||
thought_events = [e for e in qm.events if isinstance(e, QueueAgentThoughtEvent)]
|
||||
assert len(thought_events) == 2
|
||||
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].answer == "hello agent"
|
||||
assert rows[0].thought == ""
|
||||
assert rows[0].tool == ""
|
||||
|
||||
|
||||
def test_agent_message_deltas_are_debounced_to_agent_message(monkeypatch):
|
||||
monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0, 0.2))
|
||||
fake_session = _FakeDbSession()
|
||||
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
|
||||
client = _StreamingFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store, text_delta_debounce_seconds=0.5), qm)
|
||||
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)]
|
||||
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello agent"]
|
||||
assert [event.chunk.delta.message.content for event in agent_message_events] == ["hello agent"]
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch):
|
||||
@ -559,10 +776,12 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch):
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store, text_delta_debounce_seconds=0), qm)
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)]
|
||||
assert [event.chunk.delta.message.content for event in chunk_events] == ["final answer"]
|
||||
assert [event.chunk.delta.message.content for event in agent_message_events] == ["final answer"]
|
||||
thought_events = [e for e in qm.events if isinstance(e, QueueAgentThoughtEvent)]
|
||||
assert len(thought_events) >= 3
|
||||
|
||||
@ -572,49 +791,26 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch):
|
||||
assert rows[1].tool == "bash"
|
||||
assert rows[1].tool_input == '{"cmd": "ls"}'
|
||||
assert rows[1].observation == "ok"
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
def test_streaming_turn_batches_text_deltas_within_debounce_window(monkeypatch):
|
||||
monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0, 0.2))
|
||||
client = _StreamingFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store, text_delta_debounce_seconds=0.5), qm)
|
||||
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello agent"]
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].llm_result.message.content == "hello agent"
|
||||
|
||||
|
||||
def test_streaming_turn_flushes_pending_text_before_terminal_success(monkeypatch):
|
||||
monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0))
|
||||
client = _StreamingPartStartFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store, text_delta_debounce_seconds=0.5), qm)
|
||||
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello", " agent"]
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].llm_result.message.content == "hello agent"
|
||||
|
||||
|
||||
def test_streaming_turn_flushes_pending_text_before_stop_and_cancel(monkeypatch):
|
||||
monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0))
|
||||
def test_streaming_turn_cancels_after_persisting_seen_agent_answer(monkeypatch):
|
||||
fake_session = _FakeDbSession()
|
||||
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
client = _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(queue_manager=qm)
|
||||
|
||||
with pytest.raises(GenerateTaskStoppedError):
|
||||
_run(_runner(client, store, text_delta_debounce_seconds=0.5), qm)
|
||||
_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] == ["hello "]
|
||||
agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)]
|
||||
assert chunk_events == []
|
||||
assert [event.chunk.delta.message.content for event in agent_message_events] == ["hello "]
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].answer == "hello "
|
||||
assert client.cancelled_run_ids == ["fake-run-1"]
|
||||
|
||||
|
||||
@ -662,6 +858,143 @@ def test_tool_result_without_identity_does_not_attach_to_previous_tool(monkeypat
|
||||
assert rows[1].observation == "Knowledge base search results: browser skill"
|
||||
|
||||
|
||||
def test_answer_suffix_trim_keeps_non_terminal_prefix(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.append_answer_text("intermediate final answer")
|
||||
recorder.trim_answer_suffix("final answer")
|
||||
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].answer == "intermediate "
|
||||
|
||||
|
||||
def test_tool_call_part_binds_late_call_id_to_delta_row(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": "part_delta",
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"part_delta_kind": "tool_call",
|
||||
"tool_name_delta": "knowledge_base_search",
|
||||
"args_delta": {"query": "browser"},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
recorder.handle_stream_event(
|
||||
AgentBackendStreamInternalEvent(
|
||||
run_id="run-1",
|
||||
data={
|
||||
"event_kind": "part_start",
|
||||
"index": 0,
|
||||
"part": {
|
||||
"part_kind": "tool-call",
|
||||
"tool_name": "knowledge_base_search",
|
||||
"args": {"query": "browser"},
|
||||
"tool_call_id": "tool-call-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
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",
|
||||
"tool_call_id": "tool-call-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
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_thinking_after_tool_starts_new_snapshot_row(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": "part_delta",
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"part_delta_kind": "thinking",
|
||||
"content_delta": "The first thought.",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
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": {"cmd": "date"},
|
||||
"tool_call_id": "tool-call-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
recorder.handle_stream_event(
|
||||
AgentBackendStreamInternalEvent(
|
||||
run_id="run-1",
|
||||
data={
|
||||
"event_kind": "part_delta",
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"part_delta_kind": "thinking",
|
||||
"content_delta": "The next thought.",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert [row.thought for row in rows] == ["The first thought.", "", "The next thought."]
|
||||
assert rows[0].id != rows[2].id
|
||||
assert rows[1].tool == "shell_run"
|
||||
assert rows[1].tool_input == '{"cmd": "date"}'
|
||||
|
||||
|
||||
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)
|
||||
@ -743,31 +1076,6 @@ 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()
|
||||
@ -792,11 +1100,11 @@ def test_stopped_task_cancels_agent_backend_run_and_skips_session_save():
|
||||
assert store.saved == []
|
||||
|
||||
|
||||
def test_extract_answer_handles_plain_string_and_dict():
|
||||
assert AgentAppRunner._extract_answer(None) == ""
|
||||
assert AgentAppRunner._extract_answer("plain text") == "plain text"
|
||||
assert AgentAppRunner._extract_answer({"text": "hi"}) == "hi"
|
||||
assert AgentAppRunner._extract_answer({"a": 1}) == '{"a": 1}'
|
||||
def test_terminal_output_to_answer_handles_plain_string_and_dict():
|
||||
assert AgentAppRunner._terminal_output_to_answer(None) == ""
|
||||
assert AgentAppRunner._terminal_output_to_answer("plain text") == "plain text"
|
||||
assert AgentAppRunner._terminal_output_to_answer({"text": "hi"}) == "hi"
|
||||
assert AgentAppRunner._terminal_output_to_answer({"a": 1}) == '{"a": 1}'
|
||||
|
||||
|
||||
def test_ask_human_pauses_turn_creates_form_and_persists_correlation():
|
||||
@ -827,6 +1135,22 @@ def test_ask_human_pauses_turn_creates_form_and_persists_correlation():
|
||||
assert store.saved[0][5] == "fake-ask-human-1"
|
||||
|
||||
|
||||
def test_delete_on_exit_deferred_tool_marks_session_cleaned_and_raises_error():
|
||||
client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.PAUSED)
|
||||
store = _FakeSessionStore()
|
||||
store.mark_cleaned = MagicMock(side_effect=RuntimeError("cleanup failed")) # type: ignore[method-assign]
|
||||
qm = _FakeQueueManager()
|
||||
runner = _runner(client, store)
|
||||
runner._pause_for_ask_human = MagicMock()
|
||||
|
||||
with pytest.raises(AgentBackendError, match="finalization cannot pause for human input"):
|
||||
_run(runner, qm, agent_runtime_exit_intent="delete")
|
||||
|
||||
runner._pause_for_ask_human.assert_not_called()
|
||||
assert store.saved == []
|
||||
store.mark_cleaned.assert_called_once()
|
||||
|
||||
|
||||
def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch):
|
||||
# ENG-638: a turn that runs while a pending form is answered threads the
|
||||
# human's reply into the request as deferred_tool_results.
|
||||
|
||||
@ -57,7 +57,6 @@ class TestBuildForAgentApp:
|
||||
"llm",
|
||||
]
|
||||
assert "workflow_node_job_prompt" not in names
|
||||
assert request.purpose == "agent_app"
|
||||
# Agent App keeps layers alive across turns by default.
|
||||
assert request.on_exit.default.value == "suspend"
|
||||
|
||||
@ -143,6 +142,7 @@ def _ctx(
|
||||
*,
|
||||
query: str = "hello",
|
||||
agent_config_version_kind: str = "snapshot",
|
||||
suspend_on_exit: bool = True,
|
||||
) -> AgentAppRuntimeBuildContext:
|
||||
dify_context = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
@ -160,6 +160,7 @@ def _ctx(
|
||||
user_query=query,
|
||||
idempotency_key="msg-1",
|
||||
agent_config_version_kind=agent_config_version_kind, # type: ignore[arg-type]
|
||||
suspend_on_exit=suspend_on_exit,
|
||||
)
|
||||
|
||||
|
||||
@ -185,7 +186,6 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
result = builder.build(_ctx(_soul_with_model()))
|
||||
|
||||
req = result.request
|
||||
assert req.purpose == "agent_app"
|
||||
names = [layer.name for layer in req.composition.layers]
|
||||
assert names == [
|
||||
"agent_soul_prompt",
|
||||
@ -207,10 +207,52 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
assert exec_ctx.config.user_from == "end-user"
|
||||
assert exec_ctx.config.invoke_from == "web-app"
|
||||
assert exec_ctx.config.agent_mode == "agent_app"
|
||||
assert req.on_exit.default.value == "suspend"
|
||||
# credentials are redacted in the log-safe view.
|
||||
assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]"
|
||||
assert result.metadata["conversation_id"] == "conv-1"
|
||||
|
||||
def test_build_wraps_agent_soul_prompt_for_build_draft(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(_soul_with_model(), agent_config_version_kind="build_draft"))
|
||||
|
||||
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert prompt_layer.config.prefix != _soul_with_model().prompt
|
||||
assert prompt_layer.config.prefix.startswith("You are running in build mode.")
|
||||
assert "```text\nYou are Iris.\n```" in prompt_layer.config.prefix
|
||||
|
||||
def test_build_propagates_draft_version_kind_without_wrapping_prompt(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(_soul_with_model(), agent_config_version_kind="draft"))
|
||||
|
||||
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
execution_context = next(
|
||||
layer for layer in result.request.composition.layers if layer.name == "execution_context"
|
||||
)
|
||||
config_layer = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
|
||||
|
||||
assert prompt_layer.config.prefix == "You are Iris."
|
||||
assert execution_context.config.agent_config_version_kind == "draft"
|
||||
assert config_layer.config.config_version.kind == "draft"
|
||||
|
||||
def test_build_uses_delete_on_exit_when_requested(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(_soul_with_model(), suspend_on_exit=False))
|
||||
|
||||
assert result.request.on_exit.default.value == "delete"
|
||||
|
||||
def test_build_includes_plugin_tools_layer_returned_by_injected_builder_for_draft(self):
|
||||
soul = _soul_with_model()
|
||||
soul.tools.dify_tools = [
|
||||
|
||||
@ -303,3 +303,46 @@ def test_load_active_session_for_conversation_isolates_other_conversations():
|
||||
store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-A")
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def test_list_active_sessions_for_conversation_returns_all_active_rows():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
with session_factory.create_session() as session:
|
||||
session.add(
|
||||
AgentRuntimeSession(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-1",
|
||||
conversation_id="conv-1",
|
||||
backend_run_id="run-1",
|
||||
session_snapshot=_snapshot(messages=1).model_dump_json(),
|
||||
composition_layer_specs='[{"name":"execution_context","type":"dify.execution_context","deps":{},"metadata":{},"config":{"tenant_id":"tenant-1"}},{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]',
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
AgentRuntimeSession(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id="agent-2",
|
||||
agent_config_snapshot_id="snap-2",
|
||||
conversation_id="conv-1",
|
||||
backend_run_id="run-2",
|
||||
session_snapshot=_snapshot(messages=2).model_dump_json(),
|
||||
composition_layer_specs='[{"name":"execution_context","type":"dify.execution_context","deps":{},"metadata":{},"config":{"tenant_id":"tenant-1"}},{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]',
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
loaded = store.list_active_sessions_for_conversation(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
conversation_id="conv-1",
|
||||
)
|
||||
|
||||
assert [session.scope.agent_id for session in loaded] == ["agent-2", "agent-1"]
|
||||
assert all(session.scope.conversation_id == "conv-1" for session in loaded)
|
||||
|
||||
@ -203,6 +203,7 @@ def _agent_thought() -> MessageAgentThought:
|
||||
tool="tool",
|
||||
tool_labels_str="{}",
|
||||
tool_input="input",
|
||||
answer="answer",
|
||||
message_files="[]",
|
||||
)
|
||||
thought.id = "thought"
|
||||
@ -678,7 +679,7 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
assert agent_response.answer == "agent"
|
||||
assert isinstance(responses[-1], ErrorStreamResponse)
|
||||
assert isinstance(responses[-1].err, ValueError)
|
||||
assert pipeline._task_state.llm_result.message.content == "annotatedagent"
|
||||
assert pipeline._task_state.llm_result.message.content == "annotated"
|
||||
|
||||
def test_agent_thought_to_stream_response_returns_payload(self, monkeypatch: pytest.MonkeyPatch):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
@ -720,6 +721,7 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
|
||||
assert response is not None
|
||||
assert response.id == "thought"
|
||||
assert response.thought == "t"
|
||||
|
||||
def test_agent_thought_to_stream_response_normalizes_null_display_fields(self, monkeypatch: pytest.MonkeyPatch):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
@ -735,6 +737,7 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
|
||||
agent_thought = _agent_thought()
|
||||
agent_thought.thought = None
|
||||
agent_thought.answer = None
|
||||
agent_thought.observation = None
|
||||
agent_thought.tool = None
|
||||
agent_thought.tool_input = None
|
||||
|
||||
@ -498,6 +498,37 @@ def test_agent_node_failed_run_without_session_store_skips_mark_cleaned():
|
||||
assert "session_snapshot_cleaned_on_failure" not in agent_backend
|
||||
|
||||
|
||||
def test_agent_node_failed_run_enqueues_backend_cleanup_before_local_retirement(monkeypatch):
|
||||
store = FakeSessionStore()
|
||||
store.loaded_session = StoredWorkflowAgentSession(
|
||||
scope=_pending_session(CompositorSessionSnapshot(layers=[])).scope,
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
backend_run_id="stored-run-1",
|
||||
runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")],
|
||||
)
|
||||
queued_payloads: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.agent_node.cleanup_workflow_agent_runtime_session.delay",
|
||||
lambda payload: queued_payloads.append(payload),
|
||||
)
|
||||
|
||||
events = list(_node(scenario=FakeAgentBackendScenario.FAILED, session_store=store)._run())
|
||||
|
||||
assert len(events) == 1
|
||||
result = cast(StreamCompletedEvent, events[0]).node_run_result
|
||||
assert result.status == WorkflowNodeExecutionStatus.FAILED
|
||||
assert store.cleaned[0][1] == "fake-run-1"
|
||||
assert store.cleaned[0][0].workflow_run_id == "workflow-run-1"
|
||||
assert store.cleaned[0][0].node_id == "agent-node"
|
||||
assert len(queued_payloads) == 1
|
||||
assert (
|
||||
queued_payloads[0]["idempotency_key"]
|
||||
== "tenant-1:workflow-run-1:agent-node:binding-1:workflow-agent-failure-cleanup:stored-run-1:fake-run-1"
|
||||
)
|
||||
assert queued_payloads[0]["metadata"]["previous_agent_backend_run_id"] == "stored-run-1"
|
||||
assert queued_payloads[0]["metadata"]["failed_agent_backend_run_id"] == "fake-run-1"
|
||||
|
||||
|
||||
def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot():
|
||||
store = FakeSessionStore()
|
||||
node = _node(scenario=FakeAgentBackendScenario.PAUSED, session_store=store)
|
||||
|
||||
@ -429,6 +429,8 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
|
||||
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 "accepted file-mapping shape and the returned `reference`" in output_description
|
||||
assert "include the returned `download_url` in that reply" 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}
|
||||
@ -728,7 +730,11 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
|
||||
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
|
||||
"weights": {
|
||||
"weight_type": "weighted_score",
|
||||
"vector_setting": {"vector_weight": 0.7},
|
||||
"keyword_setting": {"keyword_weight": 0.3},
|
||||
},
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "manual",
|
||||
@ -795,7 +801,10 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
|
||||
"reranking_mode": "reranking_model",
|
||||
"reranking_enable": True,
|
||||
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
|
||||
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
|
||||
"weights": {
|
||||
"vector_setting": {"vector_weight": 0.7},
|
||||
"keyword_setting": {"keyword_weight": 0.3},
|
||||
},
|
||||
"model": None,
|
||||
},
|
||||
"metadata_filtering": {
|
||||
@ -1174,6 +1183,37 @@ def test_previous_node_file_output_uses_agent_stub_download_mapping_in_workflow_
|
||||
}
|
||||
|
||||
|
||||
def test_previous_node_file_mapping_strips_extra_fields_in_workflow_context():
|
||||
file_reference = build_file_reference(record_id="tool-file-1")
|
||||
|
||||
class FileMappingVariablePool(FakeVariablePool):
|
||||
def get(self, selector):
|
||||
if list(selector) == ["previous-node", "report"]:
|
||||
return SimpleNamespace(
|
||||
value={
|
||||
"filename": "report.pdf",
|
||||
"transfer_method": "tool_file",
|
||||
"reference": file_reference,
|
||||
"external": True,
|
||||
}
|
||||
)
|
||||
return super().get(selector)
|
||||
|
||||
context = replace(_context(), variable_pool=FileMappingVariablePool())
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "Review {{#previous-node.report#}} before responding.",
|
||||
}
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
assert _previous_node_prompt_payload(result, "previous-node.report") == {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": file_reference,
|
||||
}
|
||||
|
||||
|
||||
def test_scalar_previous_node_output_appears_in_workflow_context_section():
|
||||
context = _context()
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
from datetime import UTC
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||
from agenton.layers.base import LifecycleState
|
||||
from dify_agent.protocol import CancelRunRequest, RunEvent, RunStatusResponse
|
||||
from dify_agent.protocol import RuntimeLayerSpec
|
||||
|
||||
from clients.agent_backend import AgentBackendRunRequestBuilder, FakeAgentBackendRunClient, RuntimeLayerSpec
|
||||
from clients.agent_backend.errors import AgentBackendHTTPError
|
||||
from core.workflow.nodes.agent_v2.session_cleanup_layer import WorkflowAgentSessionCleanupLayer
|
||||
from core.workflow.nodes.agent_v2.session_cleanup_layer import (
|
||||
WorkflowAgentSessionCleanupLayer,
|
||||
build_workflow_agent_session_cleanup_layer,
|
||||
)
|
||||
from core.workflow.nodes.agent_v2.session_store import (
|
||||
StoredWorkflowAgentSession,
|
||||
WorkflowAgentRuntimeSessionStore,
|
||||
@ -37,13 +37,21 @@ def _layer_snapshot(name: str) -> LayerSessionSnapshot:
|
||||
)
|
||||
|
||||
|
||||
def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> StoredWorkflowAgentSession:
|
||||
"""A typical stored session with prompt + execution_context + history + llm specs.
|
||||
def _default_scope() -> WorkflowAgentSessionScope:
|
||||
return WorkflowAgentSessionScope(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="workflow-run-1",
|
||||
node_id="agent-node",
|
||||
node_execution_id="node-exec-1",
|
||||
binding_id="binding-1",
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snapshot-1",
|
||||
)
|
||||
|
||||
The LLM layer is *not* in ``runtime_layer_specs`` because the cleanup
|
||||
contract excludes credential-bearing plugin layers, but it *is* present in
|
||||
the saved snapshot so the layer's filter logic gets exercised.
|
||||
"""
|
||||
|
||||
def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> StoredWorkflowAgentSession:
|
||||
return StoredWorkflowAgentSession(
|
||||
scope=scope,
|
||||
session_snapshot=CompositorSessionSnapshot(
|
||||
@ -64,8 +72,6 @@ def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> Stor
|
||||
|
||||
|
||||
class FakeSessionStore:
|
||||
"""In-memory stand-in for ``WorkflowAgentRuntimeSessionStore``."""
|
||||
|
||||
def __init__(self, *, stored: list[StoredWorkflowAgentSession] | None = None) -> None:
|
||||
self._stored = stored if stored is not None else [_stored_session(_default_scope())]
|
||||
self.list_calls: list[str] = []
|
||||
@ -79,69 +85,7 @@ class FakeSessionStore:
|
||||
self.cleaned.append((scope, backend_run_id))
|
||||
|
||||
|
||||
def _default_scope() -> WorkflowAgentSessionScope:
|
||||
return WorkflowAgentSessionScope(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="workflow-run-1",
|
||||
node_id="agent-node",
|
||||
node_execution_id="node-exec-1",
|
||||
binding_id="binding-1",
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snapshot-1",
|
||||
)
|
||||
|
||||
|
||||
class _WaitableFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
"""``FakeAgentBackendRunClient`` plus the ``wait_run`` hook the layer needs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
run_id: str = "cleanup-run-1",
|
||||
wait_status: str = "succeeded",
|
||||
wait_error: str | None = None,
|
||||
wait_raises: Exception | None = None,
|
||||
) -> None:
|
||||
super().__init__(run_id=run_id)
|
||||
self._wait_status = wait_status
|
||||
self._wait_error = wait_error
|
||||
self._wait_raises = wait_raises
|
||||
self.wait_calls: list[tuple[str, float | None]] = []
|
||||
|
||||
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
||||
self.wait_calls.append((run_id, timeout_seconds))
|
||||
if self._wait_raises is not None:
|
||||
raise self._wait_raises
|
||||
from datetime import datetime
|
||||
|
||||
return RunStatusResponse(
|
||||
run_id=run_id,
|
||||
status=cast(object, self._wait_status), # protocol Literal; cast keeps tests flexible
|
||||
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
error=self._wait_error,
|
||||
)
|
||||
|
||||
# Inherit ``create_run`` from FakeAgentBackendRunClient; the missing protocol
|
||||
# methods below are stub-only because the cleanup layer never calls them.
|
||||
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None): # pragma: no cover
|
||||
del run_id, request
|
||||
raise NotImplementedError
|
||||
|
||||
def stream_events(self, run_id: str, *, after: str | None = None): # pragma: no cover
|
||||
del run_id, after
|
||||
if False:
|
||||
yield cast(RunEvent, None)
|
||||
|
||||
|
||||
def _build_layer(
|
||||
*,
|
||||
session_store: FakeSessionStore,
|
||||
agent_backend_client: _WaitableFakeAgentBackendRunClient,
|
||||
http_cleanup_supported: bool = True,
|
||||
) -> WorkflowAgentSessionCleanupLayer:
|
||||
def _build_layer(*, session_store: FakeSessionStore) -> WorkflowAgentSessionCleanupLayer:
|
||||
variable_pool = VariablePool.from_bootstrap(
|
||||
system_variables=build_system_variables(workflow_execution_id="workflow-run-1"),
|
||||
user_inputs={},
|
||||
@ -150,12 +94,7 @@ def _build_layer(
|
||||
runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
|
||||
layer = WorkflowAgentSessionCleanupLayer(
|
||||
session_store=cast(WorkflowAgentRuntimeSessionStore, session_store),
|
||||
request_builder=AgentBackendRunRequestBuilder(),
|
||||
agent_backend_client=agent_backend_client,
|
||||
)
|
||||
# Tests opt in to the future HTTP-cleanup branch; the production default
|
||||
# (False) is exercised by the dedicated tests below.
|
||||
layer._HTTP_CLEANUP_SUPPORTED = http_cleanup_supported # type: ignore[reportPrivateUsage]
|
||||
layer.initialize(ReadOnlyGraphRuntimeStateWrapper(runtime_state), cast(CommandChannel, object()))
|
||||
return layer
|
||||
|
||||
@ -170,30 +109,22 @@ def _build_layer(
|
||||
],
|
||||
ids=["succeeded", "partial_succeeded", "failed", "aborted"],
|
||||
)
|
||||
def test_cleanup_layer_triggers_cleanup_only_run_on_each_terminal_event(terminal_event):
|
||||
def test_cleanup_layer_enqueues_cleanup_and_marks_cleaned_on_terminal_events(monkeypatch, terminal_event):
|
||||
session_store = FakeSessionStore()
|
||||
agent_backend_client = _WaitableFakeAgentBackendRunClient()
|
||||
layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client)
|
||||
queued_payloads: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.session_cleanup_layer.cleanup_workflow_agent_runtime_session.delay",
|
||||
lambda payload: queued_payloads.append(payload),
|
||||
)
|
||||
layer = _build_layer(session_store=session_store)
|
||||
|
||||
layer.on_event(terminal_event)
|
||||
|
||||
assert session_store.list_calls == ["workflow-run-1"]
|
||||
assert agent_backend_client.request is not None
|
||||
# Cleanup composition replays the persisted (non-plugin) layer specs so the
|
||||
# agent backend's snapshot-vs-composition name match succeeds.
|
||||
layer_names = [layer.name for layer in agent_backend_client.request.composition.layers]
|
||||
assert layer_names == ["workflow_node_job_prompt", "execution_context", "history"]
|
||||
assert agent_backend_client.request.on_exit.default.value == "delete"
|
||||
assert agent_backend_client.request.metadata["agent_backend_lifecycle"] == "session_cleanup"
|
||||
# Snapshot is filtered to drop the plugin layer entry so names match the
|
||||
# cleanup composition.
|
||||
assert agent_backend_client.request.session_snapshot is not None
|
||||
snapshot_names = [layer.name for layer in agent_backend_client.request.session_snapshot.layers]
|
||||
assert snapshot_names == ["workflow_node_job_prompt", "execution_context", "history"]
|
||||
# The layer waited for terminal status and the run succeeded, so the row
|
||||
# is marked CLEANED with the cleanup run id.
|
||||
assert agent_backend_client.wait_calls
|
||||
assert session_store.cleaned == [(_default_scope(), "cleanup-run-1")]
|
||||
assert len(queued_payloads) == 1
|
||||
assert queued_payloads[0]["metadata"]["workflow_run_id"] == "workflow-run-1"
|
||||
assert queued_payloads[0]["metadata"]["previous_agent_backend_run_id"] == "agent-run-1"
|
||||
assert session_store.cleaned == [(_default_scope(), "agent-run-1")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -204,91 +135,79 @@ def test_cleanup_layer_triggers_cleanup_only_run_on_each_terminal_event(terminal
|
||||
],
|
||||
ids=["started", "paused"],
|
||||
)
|
||||
def test_cleanup_layer_ignores_non_terminal_events(non_terminal_event):
|
||||
def test_cleanup_layer_ignores_non_terminal_events(monkeypatch, non_terminal_event):
|
||||
session_store = FakeSessionStore()
|
||||
agent_backend_client = _WaitableFakeAgentBackendRunClient()
|
||||
layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client)
|
||||
queued_payloads: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.session_cleanup_layer.cleanup_workflow_agent_runtime_session.delay",
|
||||
lambda payload: queued_payloads.append(payload),
|
||||
)
|
||||
layer = _build_layer(session_store=session_store)
|
||||
|
||||
layer.on_event(non_terminal_event)
|
||||
|
||||
assert session_store.list_calls == []
|
||||
assert agent_backend_client.request is None
|
||||
assert queued_payloads == []
|
||||
assert session_store.cleaned == []
|
||||
|
||||
|
||||
def test_cleanup_layer_does_not_mark_cleaned_when_cleanup_run_fails():
|
||||
"""Trap D: cleanup-only run goes ``run_failed`` (e.g. snapshot validation
|
||||
error) — the layer must leave the row ACTIVE so it can be retried instead
|
||||
of silently leaking suspended agent-backend layers."""
|
||||
session_store = FakeSessionStore()
|
||||
agent_backend_client = _WaitableFakeAgentBackendRunClient(
|
||||
wait_status="failed",
|
||||
wait_error="snapshot mismatch",
|
||||
def test_cleanup_layer_marks_cleaned_even_when_specs_are_missing(monkeypatch, caplog: pytest.LogCaptureFixture):
|
||||
scope = _default_scope()
|
||||
session_store = FakeSessionStore(
|
||||
stored=[
|
||||
StoredWorkflowAgentSession(
|
||||
scope=scope,
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[_layer_snapshot("history")]),
|
||||
backend_run_id="legacy-run",
|
||||
runtime_layer_specs=[],
|
||||
)
|
||||
]
|
||||
)
|
||||
layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client)
|
||||
queued_payloads: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.session_cleanup_layer.cleanup_workflow_agent_runtime_session.delay",
|
||||
lambda payload: queued_payloads.append(payload),
|
||||
)
|
||||
layer = _build_layer(session_store=session_store)
|
||||
|
||||
layer.on_event(GraphRunSucceededEvent(outputs={}))
|
||||
|
||||
assert agent_backend_client.wait_calls
|
||||
assert session_store.cleaned == []
|
||||
assert queued_payloads == []
|
||||
assert session_store.cleaned == [(scope, "legacy-run")]
|
||||
assert any("no runtime_layer_specs persisted" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_cleanup_layer_does_not_mark_cleaned_when_wait_raises():
|
||||
def test_cleanup_layer_marks_cleaned_even_when_enqueue_fails(monkeypatch):
|
||||
session_store = FakeSessionStore()
|
||||
agent_backend_client = _WaitableFakeAgentBackendRunClient(
|
||||
wait_raises=AgentBackendHTTPError("boom", status_code=500, detail=None),
|
||||
|
||||
def _explode(_payload: dict[str, object]) -> None:
|
||||
raise RuntimeError("queue down")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.session_cleanup_layer.cleanup_workflow_agent_runtime_session.delay",
|
||||
_explode,
|
||||
)
|
||||
layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client)
|
||||
layer = _build_layer(session_store=session_store)
|
||||
|
||||
layer.on_event(GraphRunSucceededEvent(outputs={}))
|
||||
|
||||
assert session_store.cleaned == []
|
||||
|
||||
|
||||
def test_cleanup_layer_marks_cleaned_locally_when_http_cleanup_disabled():
|
||||
"""Production default: dify-agent has no cleanup-only run mode yet, so the
|
||||
layer must retire the local row without issuing a doomed HTTP request that
|
||||
would crash inside the agent backend's runner on the missing LLM layer."""
|
||||
session_store = FakeSessionStore()
|
||||
agent_backend_client = _WaitableFakeAgentBackendRunClient()
|
||||
layer = _build_layer(
|
||||
session_store=session_store,
|
||||
agent_backend_client=agent_backend_client,
|
||||
http_cleanup_supported=False,
|
||||
)
|
||||
|
||||
layer.on_event(GraphRunSucceededEvent(outputs={}))
|
||||
|
||||
# No HTTP call goes out — the trap is avoided entirely.
|
||||
assert agent_backend_client.request is None
|
||||
assert agent_backend_client.wait_calls == []
|
||||
# Local row is still retired so a workflow loop cannot resume from stale state.
|
||||
assert session_store.cleaned == [(_default_scope(), "agent-run-1")]
|
||||
|
||||
|
||||
def test_cleanup_layer_skips_sessions_without_persisted_specs():
|
||||
"""Backwards-compatible safety net: a row written before A.1 landed has
|
||||
no runtime_layer_specs, so cleanup would unavoidably hit the snapshot-
|
||||
validation trap. The layer must skip such rows instead of issuing a
|
||||
doomed request."""
|
||||
scope = _default_scope()
|
||||
legacy_session = StoredWorkflowAgentSession(
|
||||
scope=scope,
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[_layer_snapshot("history")]),
|
||||
backend_run_id="legacy-run",
|
||||
runtime_layer_specs=[],
|
||||
)
|
||||
session_store = FakeSessionStore(stored=[legacy_session])
|
||||
agent_backend_client = _WaitableFakeAgentBackendRunClient()
|
||||
layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client)
|
||||
def test_cleanup_layer_does_not_raise_when_mark_cleaned_fails(monkeypatch):
|
||||
session_store = FakeSessionStore()
|
||||
|
||||
def _explode(*, scope: WorkflowAgentSessionScope, backend_run_id: str | None = None) -> None:
|
||||
del scope, backend_run_id
|
||||
raise RuntimeError("cleanup bookkeeping failed")
|
||||
|
||||
monkeypatch.setattr(session_store, "mark_cleaned", _explode)
|
||||
layer = _build_layer(session_store=session_store)
|
||||
|
||||
layer.on_event(GraphRunSucceededEvent(outputs={}))
|
||||
|
||||
assert agent_backend_client.request is None
|
||||
assert session_store.cleaned == []
|
||||
|
||||
|
||||
def test_cleanup_layer_fans_out_to_every_active_session():
|
||||
def test_cleanup_layer_fans_out_to_every_active_session(monkeypatch):
|
||||
scopes = [
|
||||
WorkflowAgentSessionScope(
|
||||
tenant_id="tenant-1",
|
||||
@ -304,109 +223,34 @@ def test_cleanup_layer_fans_out_to_every_active_session():
|
||||
for i in range(3)
|
||||
]
|
||||
session_store = FakeSessionStore(stored=[_stored_session(scope, index=i) for i, scope in enumerate(scopes, 1)])
|
||||
agent_backend_client = _WaitableFakeAgentBackendRunClient(run_id="cleanup-run-many")
|
||||
layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client)
|
||||
queued_payloads: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.session_cleanup_layer.cleanup_workflow_agent_runtime_session.delay",
|
||||
lambda payload: queued_payloads.append(payload),
|
||||
)
|
||||
layer = _build_layer(session_store=session_store)
|
||||
|
||||
layer.on_event(GraphRunSucceededEvent(outputs={}))
|
||||
|
||||
# One cleanup row per stored ACTIVE session, all marked cleaned with the
|
||||
# backend run id returned by the agent backend client.
|
||||
assert len(queued_payloads) == 3
|
||||
assert [entry[0] for entry in session_store.cleaned] == scopes
|
||||
assert {entry[1] for entry in session_store.cleaned} == {"cleanup-run-many"}
|
||||
|
||||
|
||||
def test_cleanup_layer_warns_when_http_enabled_but_client_missing(caplog: pytest.LogCaptureFixture):
|
||||
"""The HTTP cleanup branch must defensively skip when no client was wired.
|
||||
|
||||
This is the deployment-misconfig path: ``_HTTP_CLEANUP_SUPPORTED`` was
|
||||
flipped to ``True`` but ``AGENT_BACKEND_BASE_URL`` is unset, so the
|
||||
factory returned ``None``. The layer must not crash and must not silently
|
||||
retire the row — the warning surfaces the misconfig.
|
||||
"""
|
||||
import logging
|
||||
|
||||
def test_cleanup_layer_skips_when_workflow_run_id_missing(caplog: pytest.LogCaptureFixture):
|
||||
session_store = FakeSessionStore()
|
||||
layer = WorkflowAgentSessionCleanupLayer(
|
||||
session_store=cast(WorkflowAgentRuntimeSessionStore, session_store),
|
||||
request_builder=AgentBackendRunRequestBuilder(),
|
||||
agent_backend_client=None,
|
||||
)
|
||||
layer._HTTP_CLEANUP_SUPPORTED = True # type: ignore[reportPrivateUsage]
|
||||
variable_pool = VariablePool.from_bootstrap(
|
||||
system_variables=build_system_variables(workflow_execution_id="workflow-run-1"),
|
||||
user_inputs={},
|
||||
conversation_variables=[],
|
||||
)
|
||||
variable_pool = VariablePool.from_bootstrap(system_variables={}, user_inputs={}, conversation_variables=[])
|
||||
runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
|
||||
layer = WorkflowAgentSessionCleanupLayer(session_store=cast(WorkflowAgentRuntimeSessionStore, session_store))
|
||||
layer.initialize(ReadOnlyGraphRuntimeStateWrapper(runtime_state), cast(CommandChannel, object()))
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
layer.on_event(GraphRunSucceededEvent(outputs={}))
|
||||
|
||||
assert session_store.cleaned == []
|
||||
assert any("no agent backend client is wired in" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_cleanup_layer_skips_workflow_terminal_when_workflow_run_id_missing(caplog: pytest.LogCaptureFixture):
|
||||
"""``workflow_run_id`` is the keying field; without it the fanout cannot
|
||||
target a row, so the layer logs a warning and bails."""
|
||||
import logging
|
||||
|
||||
session_store = FakeSessionStore()
|
||||
agent_backend_client = _WaitableFakeAgentBackendRunClient()
|
||||
layer = WorkflowAgentSessionCleanupLayer(
|
||||
session_store=cast(WorkflowAgentRuntimeSessionStore, session_store),
|
||||
request_builder=AgentBackendRunRequestBuilder(),
|
||||
agent_backend_client=agent_backend_client,
|
||||
)
|
||||
# Bootstrap *without* a workflow_execution_id system variable.
|
||||
variable_pool = VariablePool.from_bootstrap(
|
||||
system_variables=build_system_variables(workflow_execution_id=""),
|
||||
user_inputs={},
|
||||
conversation_variables=[],
|
||||
)
|
||||
runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
|
||||
layer.initialize(ReadOnlyGraphRuntimeStateWrapper(runtime_state), cast(CommandChannel, object()))
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
layer.on_event(GraphRunSucceededEvent(outputs={}))
|
||||
layer.on_event(GraphRunSucceededEvent(outputs={}))
|
||||
|
||||
assert session_store.list_calls == []
|
||||
assert session_store.cleaned == []
|
||||
assert any("workflow_run_id is missing" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_build_workflow_agent_session_cleanup_layer_returns_layer_without_client_when_unconfigured(
|
||||
monkeypatch,
|
||||
):
|
||||
"""The production builder must pass ``None`` for the agent backend client
|
||||
when neither AGENT_BACKEND_BASE_URL nor AGENT_BACKEND_USE_FAKE is set, so
|
||||
that unit-test environments without backend config don't crash at runner
|
||||
construction."""
|
||||
from configs import dify_config
|
||||
from core.workflow.nodes.agent_v2.session_cleanup_layer import (
|
||||
build_workflow_agent_session_cleanup_layer,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", None, raising=False)
|
||||
monkeypatch.setattr(dify_config, "AGENT_BACKEND_USE_FAKE", False, raising=False)
|
||||
|
||||
def test_build_workflow_agent_session_cleanup_layer_returns_layer() -> None:
|
||||
layer = build_workflow_agent_session_cleanup_layer()
|
||||
assert layer._agent_backend_client is None # type: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
def test_build_workflow_agent_session_cleanup_layer_returns_layer_with_fake_client(monkeypatch):
|
||||
"""With ``AGENT_BACKEND_USE_FAKE`` enabled the helper wires in the
|
||||
deterministic fake client without needing a base_url."""
|
||||
from clients.agent_backend.fake_client import FakeAgentBackendRunClient
|
||||
from configs import dify_config
|
||||
from core.workflow.nodes.agent_v2.session_cleanup_layer import (
|
||||
build_workflow_agent_session_cleanup_layer,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", None, raising=False)
|
||||
monkeypatch.setattr(dify_config, "AGENT_BACKEND_USE_FAKE", True, raising=False)
|
||||
monkeypatch.setattr(dify_config, "AGENT_BACKEND_FAKE_SCENARIO", "success", raising=False)
|
||||
|
||||
layer = build_workflow_agent_session_cleanup_layer()
|
||||
assert isinstance(layer._agent_backend_client, FakeAgentBackendRunClient) # type: ignore[reportPrivateUsage]
|
||||
assert isinstance(layer, WorkflowAgentSessionCleanupLayer)
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.protocol import RuntimeLayerSpec
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError
|
||||
@ -3170,6 +3173,159 @@ class TestAgentAppBackingAgent:
|
||||
assert session.deleted == []
|
||||
assert session.commits == 1
|
||||
|
||||
def test_refresh_agent_app_debug_conversation_enqueues_cleanup_for_old_runtime_sessions(
|
||||
self, 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,
|
||||
app_id="app-1",
|
||||
)
|
||||
mapping = SimpleNamespace(app_id="old-app", conversation_id="old-conversation")
|
||||
stored_session = SimpleNamespace(
|
||||
scope=SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
app_id="old-app",
|
||||
conversation_id="old-conversation",
|
||||
agent_id="agent-9",
|
||||
agent_config_snapshot_id="snap-9",
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
backend_run_id="run-old",
|
||||
runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")],
|
||||
)
|
||||
session = FakeSession(scalar=[agent, mapping])
|
||||
service = AgentRosterService(session)
|
||||
cleanup_delay = MagicMock()
|
||||
cleanup_store = MagicMock()
|
||||
cleanup_store.list_active_sessions_for_conversation.return_value = [stored_session]
|
||||
monkeypatch.setattr(roster_service, "AgentAppRuntimeSessionStore", lambda: cleanup_store)
|
||||
monkeypatch.setattr(roster_service.cleanup_conversation_agent_runtime_session, "delay", cleanup_delay)
|
||||
|
||||
conversation_id = service.refresh_agent_app_debug_conversation_id(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
cleanup_store.list_active_sessions_for_conversation.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
app_id="old-app",
|
||||
conversation_id="old-conversation",
|
||||
)
|
||||
cleanup_delay.assert_called_once()
|
||||
payload = cleanup_delay.call_args.args[0]
|
||||
assert payload["metadata"]["conversation_id"] == "old-conversation"
|
||||
assert payload["metadata"]["agent_id"] == "agent-9"
|
||||
assert (
|
||||
payload["idempotency_key"] == "tenant-1:agent-1:account-1:old-conversation:debug-session-cleanup:"
|
||||
"agent-9:snap-9:run-old"
|
||||
)
|
||||
cleanup_store.mark_cleaned.assert_called_once_with(
|
||||
scope=stored_session.scope,
|
||||
backend_run_id="run-old",
|
||||
)
|
||||
assert mapping.app_id == "app-1"
|
||||
assert mapping.conversation_id == conversation_id
|
||||
|
||||
def test_refresh_agent_app_debug_conversation_marks_old_runtime_sessions_clean_when_enqueue_fails(
|
||||
self, 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,
|
||||
app_id="app-1",
|
||||
)
|
||||
mapping = SimpleNamespace(app_id="old-app", conversation_id="old-conversation")
|
||||
stored_session = SimpleNamespace(
|
||||
scope=SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
app_id="old-app",
|
||||
conversation_id="old-conversation",
|
||||
agent_id="agent-9",
|
||||
agent_config_snapshot_id="snap-9",
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
backend_run_id="run-old",
|
||||
runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")],
|
||||
)
|
||||
session = FakeSession(scalar=[agent, mapping])
|
||||
service = AgentRosterService(session)
|
||||
cleanup_store = MagicMock()
|
||||
cleanup_store.list_active_sessions_for_conversation.return_value = [stored_session]
|
||||
monkeypatch.setattr(roster_service, "AgentAppRuntimeSessionStore", lambda: cleanup_store)
|
||||
monkeypatch.setattr(
|
||||
roster_service.cleanup_conversation_agent_runtime_session,
|
||||
"delay",
|
||||
MagicMock(side_effect=RuntimeError("queue down")),
|
||||
)
|
||||
|
||||
service.refresh_agent_app_debug_conversation_id(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
cleanup_store.mark_cleaned.assert_called_once_with(
|
||||
scope=stored_session.scope,
|
||||
backend_run_id="run-old",
|
||||
)
|
||||
|
||||
def test_refresh_agent_app_debug_conversation_ignores_mark_cleaned_failure(self, 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,
|
||||
app_id="app-1",
|
||||
)
|
||||
mapping = SimpleNamespace(app_id="old-app", conversation_id="old-conversation")
|
||||
stored_session = SimpleNamespace(
|
||||
scope=SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
app_id="old-app",
|
||||
conversation_id="old-conversation",
|
||||
agent_id="agent-9",
|
||||
agent_config_snapshot_id="snap-9",
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
backend_run_id="run-old",
|
||||
runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")],
|
||||
)
|
||||
session = FakeSession(scalar=[agent, mapping])
|
||||
service = AgentRosterService(session)
|
||||
cleanup_store = MagicMock()
|
||||
cleanup_store.list_active_sessions_for_conversation.return_value = [stored_session]
|
||||
cleanup_store.mark_cleaned.side_effect = RuntimeError("cleanup bookkeeping failed")
|
||||
monkeypatch.setattr(roster_service, "AgentAppRuntimeSessionStore", lambda: cleanup_store)
|
||||
monkeypatch.setattr(roster_service.cleanup_conversation_agent_runtime_session, "delay", MagicMock())
|
||||
|
||||
conversation_id = service.refresh_agent_app_debug_conversation_id(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert mapping.app_id == "app-1"
|
||||
assert mapping.conversation_id == conversation_id
|
||||
assert session.commits == 1
|
||||
|
||||
def test_duplicate_agent_app_copies_app_config_and_active_soul(self, monkeypatch: pytest.MonkeyPatch):
|
||||
source_config = SimpleNamespace(
|
||||
opening_statement="hello",
|
||||
|
||||
@ -243,9 +243,10 @@ def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig):
|
||||
|
||||
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); "
|
||||
"`dify-agent file upload <path>`, then set final_output.qna_report to a `tool_file` mapping "
|
||||
"using the returned `reference`; if replying to the user in natural language, use the returned "
|
||||
"`download_url`; 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."
|
||||
)
|
||||
|
||||
|
||||
@ -84,7 +84,12 @@ class FakeClient:
|
||||
self.locators.append(locator)
|
||||
self.calls.append(("upload", path))
|
||||
return SandboxUploadResponse(
|
||||
path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
|
||||
path=path,
|
||||
file={
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "dify-file-ref:file-1",
|
||||
"download_url": "https://files.example/report.txt?token=1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -151,7 +156,11 @@ def test_agent_app_sandbox_service_upload_returns_download_url(monkeypatch: pyte
|
||||
assert store.scope == ("tenant-1", "app-1", "conv-1")
|
||||
assert captured == {
|
||||
"tenant_id": "tenant-1",
|
||||
"file_mapping": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
"file_mapping": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "dify-file-ref:file-1",
|
||||
"download_url": "https://files.example/report.txt?token=1",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -265,7 +274,11 @@ def test_workflow_sandbox_service_resolves_locator_and_returns_download_url(
|
||||
assert client.calls == [("upload", "report.txt")]
|
||||
assert captured == {
|
||||
"tenant_id": "tenant-1",
|
||||
"file_mapping": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
"file_mapping": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "dify-file-ref:file-1",
|
||||
"download_url": "https://files.example/report.txt?token=1",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -310,22 +310,6 @@ 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()
|
||||
@ -581,73 +565,6 @@ class TestGenerateBilling:
|
||||
# exit is called in finally block for non-streaming
|
||||
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()
|
||||
|
||||
@ -30,8 +30,9 @@ def test_request_download_url_builds_file_under_bound_scope(
|
||||
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()) as bind_scope,
|
||||
patch.object(service, "_build_file", return_value=fake_file) as build_file,
|
||||
patch(
|
||||
"services.file_request_service.file_helpers.resolve_file_url", return_value="https://files.example.com/x"
|
||||
),
|
||||
"services.file_request_service.file_helpers.resolve_file_url",
|
||||
return_value="https://files.example.com/x",
|
||||
) as resolve_file_url,
|
||||
):
|
||||
result = service.request_download_url(
|
||||
tenant_id="tenant-1",
|
||||
@ -51,6 +52,7 @@ def test_request_download_url_builds_file_under_bound_scope(
|
||||
build_file.assert_called_once_with(
|
||||
mapping={"transfer_method": "tool_file", "reference": reference}, tenant_id="tenant-1"
|
||||
)
|
||||
resolve_file_url.assert_called_once_with(fake_file, for_external=True)
|
||||
assert result.filename == "report.pdf"
|
||||
assert result.mime_type == "application/pdf"
|
||||
assert result.size == 123
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import logging
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
|
||||
from clients.agent_backend.session_cleanup import (
|
||||
AgentBackendSessionCleanupPayload,
|
||||
AgentBackendSessionCleanupResult,
|
||||
)
|
||||
from tasks import agent_backend_session_cleanup_task as cleanup_task_module
|
||||
|
||||
|
||||
def _payload_dict() -> dict[str, object]:
|
||||
return AgentBackendSessionCleanupPayload(
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
runtime_layer_specs=[],
|
||||
metadata={"tenant_id": "tenant-1", "app_id": "app-1"},
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
def test_run_cleanup_task_logs_info_for_skipped_result(monkeypatch, caplog):
|
||||
monkeypatch.setattr(cleanup_task_module, "_create_agent_backend_client", lambda: object())
|
||||
monkeypatch.setattr(
|
||||
cleanup_task_module,
|
||||
"cleanup_agent_backend_session",
|
||||
lambda **kwargs: AgentBackendSessionCleanupResult.skipped("missing_runtime_layer_specs"),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="tasks.agent_backend_session_cleanup_task"):
|
||||
cleanup_task_module._run_cleanup_task(_payload_dict())
|
||||
|
||||
assert "Agent backend session cleanup skipped" in caplog.text
|
||||
assert "missing_runtime_layer_specs" in caplog.text
|
||||
|
||||
|
||||
def test_run_cleanup_task_logs_warning_for_failed_result(monkeypatch, caplog):
|
||||
monkeypatch.setattr(cleanup_task_module, "_create_agent_backend_client", lambda: object())
|
||||
monkeypatch.setattr(
|
||||
cleanup_task_module,
|
||||
"cleanup_agent_backend_session",
|
||||
lambda **kwargs: AgentBackendSessionCleanupResult.failed(
|
||||
"backend exploded",
|
||||
cleanup_run_id="cleanup-run-1",
|
||||
),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="tasks.agent_backend_session_cleanup_task"):
|
||||
cleanup_task_module._run_cleanup_task(_payload_dict())
|
||||
|
||||
assert "Agent backend session cleanup failed" in caplog.text
|
||||
assert "backend exploded" in caplog.text
|
||||
assert "cleanup-run-1" in caplog.text
|
||||
@ -1,10 +1,16 @@
|
||||
import logging
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from libs.archive_storage import ArchiveStorageNotConfiguredError
|
||||
from models import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus
|
||||
from tasks.remove_app_and_related_data_task import (
|
||||
_cleanup_active_agent_runtime_sessions_for_app,
|
||||
_delete_app_stars,
|
||||
_delete_app_workflow_archive_logs,
|
||||
_delete_archived_workflow_run_files,
|
||||
@ -14,6 +20,29 @@ from tasks.remove_app_and_related_data_task import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _create_agent_runtime_sessions_table() -> Generator[None, None, None]:
|
||||
engine = session_factory.get_session_maker().kw["bind"]
|
||||
AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True)
|
||||
yield
|
||||
with session_factory.create_session() as session:
|
||||
session.execute(delete(AgentRuntimeSession))
|
||||
session.commit()
|
||||
AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True)
|
||||
|
||||
|
||||
def _runtime_session_specs_json() -> str:
|
||||
return (
|
||||
'[{"name":"execution_context","type":"dify.execution_context","deps":{},"metadata":{},'
|
||||
'"config":{"tenant_id":"tenant-1"}},{"name":"history","type":"pydantic_ai.history","deps":{},'
|
||||
'"metadata":{},"config":null}]'
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_json() -> str:
|
||||
return CompositorSessionSnapshot(layers=[]).model_dump_json()
|
||||
|
||||
|
||||
class TestDeleteDraftVariablesBatch:
|
||||
def test_delete_draft_variables_batch_invalid_batch_size(self):
|
||||
"""Test that invalid batch size raises ValueError."""
|
||||
@ -149,3 +178,215 @@ class TestDeleteArchivedWorkflowRunFiles:
|
||||
storage.list_objects.assert_called_once_with("tenant-1/app_id=app-1/")
|
||||
storage.delete_object.assert_has_calls([call("key-1"), call("key-2")], any_order=False)
|
||||
assert "Deleted 2 archive objects for app app-1" in caplog.text
|
||||
|
||||
|
||||
class TestCleanupActiveAgentRuntimeSessionsForApp:
|
||||
@patch("tasks.remove_app_and_related_data_task.cleanup_workflow_agent_runtime_session")
|
||||
@patch("tasks.remove_app_and_related_data_task.cleanup_conversation_agent_runtime_session")
|
||||
def test_enqueues_cleanup_for_active_rows_and_marks_rows_cleaned(
|
||||
self,
|
||||
mock_conversation_cleanup,
|
||||
mock_workflow_cleanup,
|
||||
):
|
||||
with session_factory.create_session() as session:
|
||||
session.add(
|
||||
AgentRuntimeSession(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-1",
|
||||
conversation_id="conv-1",
|
||||
backend_run_id="run-conv",
|
||||
session_snapshot=_snapshot_json(),
|
||||
composition_layer_specs=_runtime_session_specs_json(),
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
AgentRuntimeSession(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
|
||||
agent_id="agent-2",
|
||||
workflow_id="wf-1",
|
||||
workflow_run_id="wf-run-1",
|
||||
node_id="node-1",
|
||||
backend_run_id="run-wf",
|
||||
session_snapshot=_snapshot_json(),
|
||||
composition_layer_specs=_runtime_session_specs_json(),
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
AgentRuntimeSession(
|
||||
tenant_id="tenant-1",
|
||||
app_id="other-app",
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id="agent-3",
|
||||
conversation_id="conv-2",
|
||||
backend_run_id="run-other",
|
||||
session_snapshot=_snapshot_json(),
|
||||
composition_layer_specs=_runtime_session_specs_json(),
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
_cleanup_active_agent_runtime_sessions_for_app("tenant-1", "app-1", batch_size=1)
|
||||
|
||||
assert mock_conversation_cleanup.delay.call_count == 1
|
||||
assert mock_workflow_cleanup.delay.call_count == 1
|
||||
conversation_payload = mock_conversation_cleanup.delay.call_args.args[0]
|
||||
workflow_payload = mock_workflow_cleanup.delay.call_args.args[0]
|
||||
assert conversation_payload["metadata"]["conversation_id"] == "conv-1"
|
||||
assert workflow_payload["metadata"]["workflow_run_id"] == "wf-run-1"
|
||||
assert conversation_payload["idempotency_key"].startswith("tenant-1:app-1:conv-1:agent-1:app-delete-cleanup:")
|
||||
assert workflow_payload["idempotency_key"].startswith(
|
||||
"tenant-1:app-1:wf-run-1:node-1:agent-2:app-delete-cleanup:"
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
app_rows = session.scalars(
|
||||
select(AgentRuntimeSession).where(
|
||||
AgentRuntimeSession.tenant_id == "tenant-1",
|
||||
AgentRuntimeSession.app_id == "app-1",
|
||||
)
|
||||
).all()
|
||||
assert {row.status for row in app_rows} == {AgentRuntimeSessionStatus.CLEANED}
|
||||
other_row = session.scalar(
|
||||
select(AgentRuntimeSession).where(
|
||||
AgentRuntimeSession.tenant_id == "tenant-1",
|
||||
AgentRuntimeSession.app_id == "other-app",
|
||||
)
|
||||
)
|
||||
assert other_row is not None
|
||||
assert other_row.status == AgentRuntimeSessionStatus.ACTIVE
|
||||
|
||||
@patch("tasks.remove_app_and_related_data_task.cleanup_workflow_agent_runtime_session")
|
||||
@patch("tasks.remove_app_and_related_data_task.cleanup_conversation_agent_runtime_session")
|
||||
def test_marks_rows_cleaned_even_when_enqueue_fails(
|
||||
self,
|
||||
mock_conversation_cleanup,
|
||||
mock_workflow_cleanup,
|
||||
):
|
||||
mock_conversation_cleanup.delay.side_effect = RuntimeError("queue down")
|
||||
with session_factory.create_session() as session:
|
||||
session.add(
|
||||
AgentRuntimeSession(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id="agent-1",
|
||||
conversation_id="conv-1",
|
||||
backend_run_id="run-conv",
|
||||
session_snapshot=_snapshot_json(),
|
||||
composition_layer_specs=_runtime_session_specs_json(),
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
AgentRuntimeSession(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
|
||||
agent_id="agent-2",
|
||||
workflow_id="wf-1",
|
||||
workflow_run_id="wf-run-1",
|
||||
node_id="node-1",
|
||||
backend_run_id="run-wf",
|
||||
session_snapshot=_snapshot_json(),
|
||||
composition_layer_specs=_runtime_session_specs_json(),
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
_cleanup_active_agent_runtime_sessions_for_app("tenant-1", "app-1")
|
||||
|
||||
mock_conversation_cleanup.delay.assert_called_once()
|
||||
mock_workflow_cleanup.delay.assert_called_once()
|
||||
with session_factory.create_session() as session:
|
||||
rows = session.scalars(
|
||||
select(AgentRuntimeSession).where(
|
||||
AgentRuntimeSession.tenant_id == "tenant-1",
|
||||
AgentRuntimeSession.app_id == "app-1",
|
||||
)
|
||||
).all()
|
||||
assert rows
|
||||
assert {row.status for row in rows} == {AgentRuntimeSessionStatus.CLEANED}
|
||||
|
||||
@patch("tasks.remove_app_and_related_data_task.cleanup_workflow_agent_runtime_session")
|
||||
@patch("tasks.remove_app_and_related_data_task.cleanup_conversation_agent_runtime_session")
|
||||
def test_uses_row_identity_to_keep_distinct_app_delete_cleanup_jobs_distinct(
|
||||
self,
|
||||
mock_conversation_cleanup,
|
||||
mock_workflow_cleanup,
|
||||
):
|
||||
del mock_workflow_cleanup
|
||||
with session_factory.create_session() as session:
|
||||
first = AgentRuntimeSession(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-1",
|
||||
conversation_id="conv-1",
|
||||
backend_run_id="run-conv-1",
|
||||
session_snapshot=_snapshot_json(),
|
||||
composition_layer_specs=_runtime_session_specs_json(),
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
second = AgentRuntimeSession(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-2",
|
||||
conversation_id="conv-1",
|
||||
backend_run_id="run-conv-2",
|
||||
session_snapshot=_snapshot_json(),
|
||||
composition_layer_specs=_runtime_session_specs_json(),
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
session.add(first)
|
||||
session.add(second)
|
||||
session.commit()
|
||||
|
||||
_cleanup_active_agent_runtime_sessions_for_app("tenant-1", "app-1")
|
||||
|
||||
assert mock_conversation_cleanup.delay.call_count == 2
|
||||
payloads = [queued_call.args[0] for queued_call in mock_conversation_cleanup.delay.call_args_list]
|
||||
assert payloads[0]["idempotency_key"] != payloads[1]["idempotency_key"]
|
||||
assert payloads[0]["idempotency_key"].endswith(first.id)
|
||||
assert payloads[1]["idempotency_key"].endswith(second.id)
|
||||
|
||||
@patch("tasks.remove_app_and_related_data_task.cleanup_workflow_agent_runtime_session")
|
||||
@patch("tasks.remove_app_and_related_data_task.cleanup_conversation_agent_runtime_session")
|
||||
def test_marks_empty_runtime_layer_specs_rows_clean_without_enqueue(
|
||||
self,
|
||||
mock_conversation_cleanup,
|
||||
mock_workflow_cleanup,
|
||||
):
|
||||
del mock_workflow_cleanup
|
||||
with session_factory.create_session() as session:
|
||||
row = AgentRuntimeSession(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id="agent-1",
|
||||
conversation_id="conv-1",
|
||||
backend_run_id="run-no-specs",
|
||||
session_snapshot=_snapshot_json(),
|
||||
composition_layer_specs="[]",
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
|
||||
_cleanup_active_agent_runtime_sessions_for_app("tenant-1", "app-1")
|
||||
|
||||
mock_conversation_cleanup.delay.assert_not_called()
|
||||
with session_factory.create_session() as session:
|
||||
stored_row = session.scalar(select(AgentRuntimeSession).where(AgentRuntimeSession.id == row.id))
|
||||
assert stored_row is not None
|
||||
assert stored_row.status == AgentRuntimeSessionStatus.CLEANED
|
||||
|
||||
@ -334,7 +334,7 @@ def _map_model_response_to_prompt_message(
|
||||
content_parts: list[PromptMessageContentUnionTypes] = []
|
||||
tool_calls: list[AssistantPromptMessage.ToolCall] = []
|
||||
|
||||
for part in message.parts:
|
||||
for index, part in enumerate(message.parts):
|
||||
if isinstance(part, TextPart):
|
||||
if part.content:
|
||||
content_parts.append(TextPromptMessageContent(data=part.content))
|
||||
@ -346,7 +346,7 @@ def _map_model_response_to_prompt_message(
|
||||
elif isinstance(part, ToolCallPart):
|
||||
tool_calls.append(
|
||||
AssistantPromptMessage.ToolCall(
|
||||
id=part.tool_call_id or f"tool-call-{part.tool_name}",
|
||||
id=part.tool_call_id or f"tool-call-{index}-{part.tool_name}",
|
||||
type="function",
|
||||
function=AssistantPromptMessage.ToolCall.ToolCallFunction(
|
||||
name=part.tool_name,
|
||||
|
||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Literal, cast
|
||||
from typing import ClassVar, Literal, Protocol, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
@ -21,11 +21,16 @@ from dify_agent.agent_stub.client import (
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping, is_canonical_dify_file_reference
|
||||
|
||||
|
||||
class _AgentStubFileDownloadResponse(Protocol):
|
||||
download_url: str
|
||||
|
||||
|
||||
class UploadedToolFileMapping(BaseModel):
|
||||
"""Canonical Agent output mapping returned by ``dify-agent file upload``."""
|
||||
|
||||
transfer_method: Literal["tool_file"] = "tool_file"
|
||||
reference: str
|
||||
download_url: str
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@ -39,9 +44,9 @@ class DownloadedFileResult:
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadedToolFileResource:
|
||||
"""Lower-level upload result carrying both public mapping and ToolFile id."""
|
||||
"""Lower-level upload result carrying the internal mapping and ToolFile id."""
|
||||
|
||||
mapping: UploadedToolFileMapping
|
||||
mapping: AgentStubFileMapping
|
||||
tool_file_id: str
|
||||
|
||||
|
||||
@ -49,11 +54,22 @@ def upload_file_from_environment(*, path: str) -> UploadedToolFileMapping:
|
||||
"""Upload one sandbox-local file through the Agent Stub control plane.
|
||||
|
||||
The signed upload data-plane response must carry the Dify-generated
|
||||
``reference`` for the new ``ToolFile`` so the sandbox can return the
|
||||
canonical Agent output file mapping without synthesizing reference format.
|
||||
``reference`` for the new ``ToolFile``. The helper then resolves the same
|
||||
mapping through the signed download-request control plane so the public CLI
|
||||
output includes a ready-to-share external ``download_url``.
|
||||
"""
|
||||
|
||||
return upload_tool_file_resource_from_environment(path=path).mapping
|
||||
resource = upload_tool_file_resource_from_environment(path=path)
|
||||
reference = resource.mapping.reference
|
||||
if not isinstance(reference, str) or not reference:
|
||||
raise AgentStubTransferError("signed file upload response is missing reference")
|
||||
environment = read_agent_stub_environment()
|
||||
download_url = _request_uploaded_tool_file_download_url(
|
||||
url=environment.url,
|
||||
auth_jwe=environment.auth_jwe,
|
||||
reference=reference,
|
||||
)
|
||||
return UploadedToolFileMapping(reference=reference, download_url=download_url)
|
||||
|
||||
|
||||
def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFileResource:
|
||||
@ -62,6 +78,8 @@ def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFile
|
||||
This lower-level helper backs ``drive push``. The signed upload data-plane
|
||||
response must include both the canonical Dify ``reference`` used by public
|
||||
CLI output and the raw ToolFile ``id`` required by drive commit payloads.
|
||||
It intentionally stops after the upload allocation so internal flows that
|
||||
only need the ToolFile identity do not pay for signed download enrichment.
|
||||
|
||||
Raises:
|
||||
AgentStubValidationError: if ``path`` does not resolve to a local file.
|
||||
@ -92,7 +110,11 @@ def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFile
|
||||
file_obj=file_obj,
|
||||
mimetype=mime_type,
|
||||
)
|
||||
return _normalize_uploaded_tool_file_resource(payload)
|
||||
reference, tool_file_id = _normalize_uploaded_tool_file_payload(payload)
|
||||
return UploadedToolFileResource(
|
||||
mapping=AgentStubFileMapping(transfer_method="tool_file", reference=reference),
|
||||
tool_file_id=tool_file_id,
|
||||
)
|
||||
|
||||
|
||||
def download_file_from_environment(
|
||||
@ -164,7 +186,7 @@ def _build_download_mapping(
|
||||
raise AgentStubValidationError("invalid file download arguments") from exc
|
||||
|
||||
|
||||
def _normalize_uploaded_tool_file_resource(payload: dict[str, object]) -> UploadedToolFileResource:
|
||||
def _normalize_uploaded_tool_file_payload(payload: dict[str, object]) -> tuple[str, str]:
|
||||
reference = payload.get("reference")
|
||||
if not isinstance(reference, str) or not reference:
|
||||
raise AgentStubTransferError("signed file upload response is missing reference")
|
||||
@ -173,10 +195,22 @@ def _normalize_uploaded_tool_file_resource(payload: dict[str, object]) -> Upload
|
||||
tool_file_id = payload.get("id")
|
||||
if not isinstance(tool_file_id, str) or not tool_file_id:
|
||||
raise AgentStubTransferError("signed file upload response is missing id")
|
||||
return UploadedToolFileResource(
|
||||
mapping=UploadedToolFileMapping(reference=reference),
|
||||
tool_file_id=tool_file_id,
|
||||
return reference, tool_file_id
|
||||
|
||||
|
||||
def _request_uploaded_tool_file_download_url(*, url: str, auth_jwe: str, reference: str) -> str:
|
||||
download_request = cast(
|
||||
_AgentStubFileDownloadResponse,
|
||||
request_agent_stub_file_download_sync(
|
||||
url=url,
|
||||
auth_jwe=auth_jwe,
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=reference),
|
||||
),
|
||||
)
|
||||
download_url = download_request.download_url
|
||||
if not isinstance(download_url, str) or not download_url:
|
||||
raise AgentStubTransferError("signed file download response is missing download_url")
|
||||
return download_url
|
||||
|
||||
|
||||
def _deduplicate_destination_path(path: Path) -> Path:
|
||||
|
||||
@ -24,6 +24,13 @@ _CONFIG_MANIFEST_STDOUT_EXCLUDE = {
|
||||
"skills": {"items": {"__all__": {"hash"}}},
|
||||
"files": {"items": {"__all__": {"hash"}}},
|
||||
}
|
||||
_FILE_DOWNLOAD_TRANSFER_METHOD_HELP = (
|
||||
"File mapping transfer_method: local_file, tool_file, datasource_file, or remote_url."
|
||||
)
|
||||
_FILE_DOWNLOAD_REFERENCE_OR_URL_HELP = (
|
||||
"File mapping reference or URL. Use dify-file-ref:... with local_file, tool_file, or datasource_file; "
|
||||
"use https://... with remote_url."
|
||||
)
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
@ -76,16 +83,18 @@ def upload(path: str = typer.Argument(..., metavar="PATH")) -> None:
|
||||
|
||||
@file_app.command("download")
|
||||
def download(
|
||||
transfer_method: str | None = typer.Argument(None, metavar="TRANSFER_METHOD"),
|
||||
reference_or_url: str | None = typer.Argument(None, metavar="REFERENCE_OR_URL"),
|
||||
mapping: str | None = typer.Option(None, "--mapping", help="Download one file from a mapping JSON object."),
|
||||
transfer_method: str = typer.Argument(..., metavar="TRANSFER_METHOD", help=_FILE_DOWNLOAD_TRANSFER_METHOD_HELP),
|
||||
reference_or_url: str = typer.Argument(
|
||||
...,
|
||||
metavar="REFERENCE_OR_URL",
|
||||
help=_FILE_DOWNLOAD_REFERENCE_OR_URL_HELP,
|
||||
),
|
||||
local_dir: str | None = typer.Option(None, "--to", help="Local directory for the downloaded file."),
|
||||
) -> None:
|
||||
"""Download one workflow file mapping into the local sandbox directory."""
|
||||
_run_file_download(
|
||||
transfer_method=transfer_method,
|
||||
reference_or_url=reference_or_url,
|
||||
mapping=mapping,
|
||||
local_dir=local_dir,
|
||||
)
|
||||
|
||||
@ -361,9 +370,8 @@ def _run_file_upload(*, path: str) -> None:
|
||||
|
||||
def _run_file_download(
|
||||
*,
|
||||
transfer_method: str | None,
|
||||
reference_or_url: str | None,
|
||||
mapping: str | None,
|
||||
transfer_method: str,
|
||||
reference_or_url: str,
|
||||
local_dir: str | None,
|
||||
) -> None:
|
||||
env_module = _env_module()
|
||||
@ -373,7 +381,6 @@ def _run_file_download(
|
||||
response = files_module.download_file_from_environment(
|
||||
transfer_method=transfer_method,
|
||||
reference_or_url=reference_or_url,
|
||||
mapping=mapping,
|
||||
local_dir=local_dir,
|
||||
)
|
||||
except env_module.MissingAgentStubEnvironmentError as exc:
|
||||
|
||||
7
dify-agent/src/dify_agent/layers/_agent_file_cli_help.py
Normal file
7
dify-agent/src/dify_agent/layers/_agent_file_cli_help.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""Shared model-facing guidance for Agent Stub file CLI usage in prompt surfaces."""
|
||||
|
||||
AGENT_FILE_UPLOAD_REPLY_HINT = (
|
||||
"When you want to provide a generated or sandbox-local file to the user in a "
|
||||
"natural-language reply, run the installed CLI command `dify-agent file upload PATH` and include the returned "
|
||||
"`download_url` so the user can open or download the file."
|
||||
)
|
||||
@ -59,6 +59,9 @@ class DifyConfigLayerConfig(LayerConfig):
|
||||
class DifyConfigRuntimeState(BaseModel):
|
||||
"""Serializable config-layer values computed once during context entry.
|
||||
|
||||
``config_cli_help`` stores pre-rendered shell-visible ``dify-agent`` help snippets
|
||||
for config commands and file upload/download commands.
|
||||
|
||||
The ``push_spec_*`` fields are compatibility leftovers from the removed root
|
||||
JSON-spec config mutation workflow. This change keeps them in the runtime-state
|
||||
schema to avoid snapshot churn, but new code should treat them as inert
|
||||
|
||||
@ -11,6 +11,7 @@ from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import LayerDeps, PlainLayer
|
||||
from dify_agent.agent_stub.cli.main import render_agent_stub_cli_help
|
||||
from dify_agent.layers._agent_file_cli_help import AGENT_FILE_UPLOAD_REPLY_HINT as _AGENT_FILE_UPLOAD_REPLY_HINT
|
||||
from dify_agent.layers.config.configs import (
|
||||
DIFY_CONFIG_LAYER_TYPE_ID,
|
||||
DifyConfigLayerConfig,
|
||||
@ -18,14 +19,19 @@ from dify_agent.layers.config.configs import (
|
||||
)
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
|
||||
_CONFIG_CONTEXT_HEADING = "Agent config context from the current Agent Soul:"
|
||||
_CONFIG_CONTEXT_COMMAND = "$ dify-agent config manifest"
|
||||
_CONFIG_CLI_USAGE_PROMPT = """Agent config CLI usage is available inside shell jobs. The command help below is generated
|
||||
from the same `dify-agent` CLI definitions available in shell jobs.
|
||||
_CONFIG_CONTEXT_HEADING = "Current Agent config manifest for this run:"
|
||||
_CONFIG_CONTEXT_COMMAND = "dify-agent config manifest"
|
||||
_CONFIG_CLI_USAGE_PROMPT = """`dify-agent` is an installed CLI tool in the shell environment. Use it directly in shell_run scripts.
|
||||
|
||||
Local edits to config files, skills, env, or notes are not saved by themselves. Config changes are saved only by a
|
||||
matching resource mutation command. Those commands are available only when the Agent config context reports
|
||||
`config_version.kind` as `build_draft` and `config_version.writable` as true."""
|
||||
The command outputs below are generated from the `dify-agent` CLI available in this run. Use them as the source of truth
|
||||
for command names, arguments, and options.
|
||||
|
||||
Config persistence rules:
|
||||
|
||||
- Local shell edits to config files, skills, env, or notes are not saved by themselves.
|
||||
- To persist an Agent config change, run the matching `dify-agent config ...` mutation command.
|
||||
- Mutation commands are available only when the manifest shows `config_version.kind` as `build_draft` and
|
||||
`config_version.writable` as true."""
|
||||
_CONFIG_CLI_HELP_COMMANDS: dict[str, tuple[str, ...]] = {
|
||||
"dify-agent config --help": ("config",),
|
||||
"dify-agent config manifest --help": ("config", "manifest"),
|
||||
@ -41,6 +47,10 @@ _CONFIG_CLI_MUTATION_HELP_COMMANDS: dict[str, tuple[str, ...]] = {
|
||||
"dify-agent config skills push --help": ("config", "skills", "push"),
|
||||
"dify-agent config skills delete --help": ("config", "skills", "delete"),
|
||||
}
|
||||
_AGENT_FILE_CLI_HELP_COMMANDS: dict[str, tuple[str, ...]] = {
|
||||
"dify-agent file upload --help": ("file", "upload"),
|
||||
"dify-agent file download --help": ("file", "download"),
|
||||
}
|
||||
_CONFIG_CONTEXT_EXCLUDE = {"mentioned_skill_names": True, "mentioned_file_names": True}
|
||||
|
||||
|
||||
@ -91,6 +101,7 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf
|
||||
command_paths = dict(_CONFIG_CLI_HELP_COMMANDS)
|
||||
if self._config_writable:
|
||||
command_paths.update(_CONFIG_CLI_MUTATION_HELP_COMMANDS)
|
||||
command_paths.update(_AGENT_FILE_CLI_HELP_COMMANDS)
|
||||
self.runtime_state.config_context_json = self._format_config_context_json()
|
||||
self.runtime_state.config_cli_help = {
|
||||
command: render_agent_stub_cli_help(args) for command, args in command_paths.items()
|
||||
@ -107,15 +118,22 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf
|
||||
output = self.runtime_state.pulled_skill_outputs.get(name)
|
||||
if output is None:
|
||||
continue
|
||||
loaded_skill_sections.append(f"Name: {name}\nPull output:\n{output}")
|
||||
command = f"dify-agent config skills pull {shlex.quote(name)}"
|
||||
loaded_skill_sections.append(
|
||||
f"Name: {name}\nPull command output for this run:\n{_format_command_output(command, output)}"
|
||||
)
|
||||
if loaded_skill_sections:
|
||||
sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections))
|
||||
|
||||
mentioned_file_sections = [
|
||||
f"Name: {name}\nPull output:\n{self.runtime_state.pulled_file_outputs[name]}"
|
||||
for name in self.config.mentioned_file_names
|
||||
if name in self.runtime_state.pulled_file_outputs
|
||||
]
|
||||
mentioned_file_sections = []
|
||||
for name in self.config.mentioned_file_names:
|
||||
output = self.runtime_state.pulled_file_outputs.get(name)
|
||||
if output is None:
|
||||
continue
|
||||
command = f"dify-agent config files pull {shlex.quote(name)}"
|
||||
mentioned_file_sections.append(
|
||||
f"Name: {name}\nPull command output for this run:\n{_format_command_output(command, output)}"
|
||||
)
|
||||
if mentioned_file_sections:
|
||||
sections.append("Mentioned files pulled locally:\n\n" + "\n\n".join(mentioned_file_sections))
|
||||
|
||||
@ -125,11 +143,14 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf
|
||||
sections: list[str] = []
|
||||
if self.runtime_state.config_context_json:
|
||||
sections.append(
|
||||
f"{_CONFIG_CONTEXT_COMMAND}\n{_CONFIG_CONTEXT_HEADING}\n{self.runtime_state.config_context_json}"
|
||||
f"{_CONFIG_CONTEXT_HEADING}\n"
|
||||
f"{_format_command_output(_CONFIG_CONTEXT_COMMAND, self.runtime_state.config_context_json)}"
|
||||
)
|
||||
usage_lines = [_CONFIG_CLI_USAGE_PROMPT]
|
||||
if cli_help := self._format_config_cli_help():
|
||||
usage_lines.append(cli_help)
|
||||
if file_cli_help := self._format_agent_file_cli_help():
|
||||
usage_lines.append(file_cli_help)
|
||||
sections.append("\n".join(usage_lines))
|
||||
return "\n\n".join(section for section in sections if section)
|
||||
|
||||
@ -142,13 +163,27 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf
|
||||
if self._config_writable:
|
||||
commands.extend(_CONFIG_CLI_MUTATION_HELP_COMMANDS)
|
||||
command_sections = [
|
||||
f"$ {command}\n{self.runtime_state.config_cli_help[command]}"
|
||||
_format_command_output(command, self.runtime_state.config_cli_help[command])
|
||||
for command in commands
|
||||
if command in self.runtime_state.config_cli_help
|
||||
]
|
||||
if not command_sections:
|
||||
return ""
|
||||
return "Agent config CLI help:\n" + "\n\n".join(command_sections)
|
||||
return "Agent config CLI reference for installed `dify-agent`:\n" + "\n\n".join(command_sections)
|
||||
|
||||
def _format_agent_file_cli_help(self) -> str:
|
||||
command_sections = [
|
||||
_format_command_output(command, self.runtime_state.config_cli_help[command])
|
||||
for command in _AGENT_FILE_CLI_HELP_COMMANDS
|
||||
if command in self.runtime_state.config_cli_help
|
||||
]
|
||||
if not command_sections:
|
||||
return ""
|
||||
return (
|
||||
"Agent file CLI reference for installed `dify-agent`:\n"
|
||||
+ "\n\n".join(command_sections)
|
||||
+ f"\n\n{_AGENT_FILE_UPLOAD_REPLY_HINT}"
|
||||
)
|
||||
|
||||
def _format_config_context_json(self) -> str:
|
||||
return self.config.model_dump_json(exclude=_CONFIG_CONTEXT_EXCLUDE, exclude_none=True)
|
||||
@ -226,4 +261,8 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_command_output(command: str, output: str) -> str:
|
||||
return f"Command:\n$ {command}\nOutput:\n{output}"
|
||||
|
||||
|
||||
__all__ = ["DifyConfigLayer", "DifyConfigLayerError"]
|
||||
|
||||
@ -21,6 +21,7 @@ from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import EmptyRuntimeState, LayerDeps, PlainLayer
|
||||
from dify_agent.agent_stub.protocol import agent_stub_drive_base_for_ref
|
||||
from dify_agent.layers._agent_file_cli_help import AGENT_FILE_UPLOAD_REPLY_HINT as _AGENT_FILE_UPLOAD_REPLY_HINT
|
||||
from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
|
||||
@ -122,13 +123,17 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim
|
||||
|
||||
def _format_agent_stub_cli_help(self) -> str:
|
||||
command_sections = [
|
||||
f"$ {command}\n{self._agent_stub_cli_help[command]}"
|
||||
_format_command_output(command, self._agent_stub_cli_help[command])
|
||||
for command in _AGENT_STUB_FILE_HELP_COMMANDS
|
||||
if command in self._agent_stub_cli_help
|
||||
]
|
||||
if not command_sections:
|
||||
return ""
|
||||
return "Agent Stub file CLI help:\n" + "\n\n".join(command_sections)
|
||||
return (
|
||||
"Agent Stub file CLI reference for installed `dify-agent`:\n"
|
||||
+ "\n\n".join(command_sections)
|
||||
+ f"\n\n{_AGENT_FILE_UPLOAD_REPLY_HINT}"
|
||||
)
|
||||
|
||||
async def _load_agent_stub_cli_help(self) -> None:
|
||||
self._agent_stub_cli_help = {}
|
||||
@ -256,4 +261,8 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim
|
||||
return f"{skill_key.rsplit('/', 1)[0]}/"
|
||||
|
||||
|
||||
def _format_command_output(command: str, output: str) -> str:
|
||||
return f"Command:\n$ {command}\nOutput:\n{output}"
|
||||
|
||||
|
||||
__all__ = ["DifyDriveLayer", "DifyDriveLayerError"]
|
||||
|
||||
@ -72,36 +72,38 @@ _SHELL_OUTPUT_PROMPT_EDGE_BYTES = 8 * 1024
|
||||
_SHELLCTL_OUTPUT_LIMIT_BYTES = 2 * _SHELL_OUTPUT_PROMPT_EDGE_BYTES
|
||||
_REMOTE_COMPLETE_OUTPUT_MAX_BYTES = 1024 * 1024
|
||||
_REMOTE_COMMAND_TIMEOUT_SECONDS = 60.0
|
||||
_SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools:
|
||||
_SHELL_LAYER_PREFIX_PROMPT = """You can run commands in an isolated shell workspace.
|
||||
|
||||
Available shell tools:
|
||||
|
||||
1. shell_run
|
||||
Start a new shell job in the current isolated workspace.
|
||||
Use it to execute commands or scripts.
|
||||
Starts a new shell job in the current workspace.
|
||||
Use it to run commands or scripts.
|
||||
|
||||
2. shell_wait
|
||||
Wait for more output or completion from an existing shell job.
|
||||
Waits for more output or completion from an existing shell job.
|
||||
Use it when shell_run returns done=false.
|
||||
|
||||
3. shell_input
|
||||
Send stdin text to a running shell job, then wait for new output.
|
||||
Use it for interactive commands that are waiting for input.
|
||||
Sends stdin text to a running shell job, then waits for new output.
|
||||
Use it only when an interactive command is waiting for input.
|
||||
|
||||
4. shell_interrupt
|
||||
Interrupt a running shell job.
|
||||
Interrupts a running shell job.
|
||||
Use it to stop a long-running, stuck, or no-longer-needed command.
|
||||
|
||||
Common arguments:
|
||||
|
||||
- script:
|
||||
The command or script to execute. Used by shell_run.
|
||||
Command or script to execute. Used by shell_run.
|
||||
|
||||
- job_id:
|
||||
The id of a shell job returned by shell_run.
|
||||
Shell job id returned by shell_run.
|
||||
Use it with shell_wait, shell_input, and shell_interrupt.
|
||||
Never invent a job_id.
|
||||
|
||||
- timeout:
|
||||
Maximum time, in seconds, to wait for output or completion for this tool call.
|
||||
Maximum time in seconds to wait for output or completion for this tool call.
|
||||
A timeout does not necessarily mean the job has stopped; if done=false, use shell_wait again.
|
||||
|
||||
- text:
|
||||
@ -118,25 +120,33 @@ Usage rules:
|
||||
- Use shell_input only when the job is running and waiting for stdin.
|
||||
- Use shell_interrupt when a job is stuck or should be stopped.
|
||||
|
||||
Installed CLI:
|
||||
|
||||
- `dify-agent` is already installed in this shell environment and can be used directly.
|
||||
- Use the generated `dify-agent ... --help` output in the config prompt for exact command syntax.
|
||||
- Do not install or recreate the `dify-agent` CLI.
|
||||
|
||||
Workspace persistence rules:
|
||||
|
||||
- The current workspace cwd is stable during this agent run, but it is temporary and may be deleted later.
|
||||
- Do not use the current workspace cwd as persistent storage.
|
||||
- $HOME outside the current workspace cwd is persistent storage. In build draft mode, when Agent config context reports
|
||||
`config_version.kind` as `build_draft` and `config_version.writable` as true, changes there can be persisted for
|
||||
later runs. In non-build-draft modes, those changes are rolled back.
|
||||
- Saving config files, skills, env, or notes still requires the corresponding Agent config CLI mutation command; follow
|
||||
the Agent config CLI help in the config layer. Shell file edits alone do not save config.
|
||||
- The current workspace cwd is stable during this run, but it is temporary and may be deleted later.
|
||||
- Do not treat files in the current workspace cwd as persisted state.
|
||||
- In build mode, config changes persist only after you run the matching `dify-agent config ...` mutation command.
|
||||
- Shell file edits alone do not save Agent config files, skills, env, or notes.
|
||||
- In non-build modes, local shell changes are not a persistence mechanism for Agent configuration.
|
||||
|
||||
The script argument of shell_run can be a normal shell script, or a shebang script.
|
||||
If the first line is a shebang, the shell layer executes the script directly.
|
||||
shell_run script rules:
|
||||
|
||||
- The script argument can be a normal shell script or a shebang script.
|
||||
- If the first line is a shebang, the shell executes the script directly.
|
||||
|
||||
Tips:
|
||||
|
||||
- When using Python, prefer a uv script with a PEP 723 dependency header.
|
||||
- If you need MCP, install the MCP server in the shell environment and start that server when you use it.
|
||||
|
||||
Example:
|
||||
Example shell_run script:
|
||||
|
||||
[begin script]
|
||||
#!/usr/bin/env -S uv run --quiet --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
@ -150,7 +160,8 @@ import httpx
|
||||
from rich import print
|
||||
|
||||
response = httpx.get("https://example.com", timeout=10)
|
||||
print(f"[green]status:[/green] {response.status_code}")"""
|
||||
print(f"[green]status:[/green] {response.status_code}")
|
||||
[end script]"""
|
||||
_SHELL_LAYER_SUFFIX_PROMPT = """Environment variables may contain API keys, tokens, or credentials.
|
||||
You may refer to environment variable names when needed."""
|
||||
|
||||
|
||||
@ -28,7 +28,6 @@ from .schemas import (
|
||||
RunEventsResponse,
|
||||
RunFailedEvent,
|
||||
RunFailedEventData,
|
||||
RunPurpose,
|
||||
RunLayerSpec,
|
||||
RunStartedEvent,
|
||||
RunStatus,
|
||||
@ -78,7 +77,6 @@ __all__ = [
|
||||
"RunEventsResponse",
|
||||
"RunFailedEvent",
|
||||
"RunFailedEventData",
|
||||
"RunPurpose",
|
||||
"RunLayerSpec",
|
||||
"RunStartedEvent",
|
||||
"RunStatus",
|
||||
|
||||
@ -112,6 +112,7 @@ class SandboxUploadedFile(BaseModel):
|
||||
|
||||
transfer_method: Literal["tool_file"] = "tool_file"
|
||||
reference: str
|
||||
download_url: str
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@ -24,10 +24,13 @@ whether each active layer is suspended or deleted when the run exits, with
|
||||
suspend as the default so successful terminal events can include resumable
|
||||
snapshots. Successful runs always publish the resumable Agenton session snapshot
|
||||
on the terminal ``run_succeeded`` event together with exactly one of the final
|
||||
JSON-safe ``output`` or a deferred external ``deferred_tool_call`` payload. That
|
||||
lets consumers treat terminal success events as complete run summaries without a
|
||||
separate pause protocol. Session snapshots carry only layer lifecycle/runtime
|
||||
state in compositor order; they do not persist output-layer config. Resumed
|
||||
JSON-safe ``output`` or a deferred external ``deferred_tool_call`` payload. A
|
||||
lifecycle-only run may also succeed with ``output = null`` and ``usage = null``
|
||||
when the composition intentionally omits the reserved model layer and only
|
||||
replays layer enter/exit work from a supplied snapshot. That lets consumers
|
||||
treat terminal success events as complete run summaries without a separate pause
|
||||
protocol. Session snapshots carry only layer lifecycle/runtime state in
|
||||
compositor order; they do not persist output-layer config. Resumed
|
||||
structured-output runs therefore must resubmit the same ``output`` layer in
|
||||
``composition.layers[]`` so snapshot layer name/order still matches the
|
||||
composition and the runtime can rebuild the same structured output contract.
|
||||
@ -50,7 +53,6 @@ DIFY_AGENT_MODEL_LAYER_ID: Final[str] = "llm"
|
||||
DIFY_AGENT_HISTORY_LAYER_ID: Final[str] = "history"
|
||||
DIFY_AGENT_OUTPUT_LAYER_ID: Final[str] = "output"
|
||||
RunStatus = Literal["running", "succeeded", "failed", "cancelled"]
|
||||
RunPurpose = Literal["workflow_node", "single_step", "agent_app", "babysit", "fasten_preview"]
|
||||
RunEventType = Literal[
|
||||
"run_started",
|
||||
"pydantic_ai_event",
|
||||
@ -136,7 +138,6 @@ class CreateRunRequest(BaseModel):
|
||||
"""
|
||||
|
||||
composition: RunComposition
|
||||
purpose: RunPurpose = "workflow_node"
|
||||
idempotency_key: str | None = None
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
@ -334,10 +335,11 @@ class RunStartedEvent(BaseRunEvent):
|
||||
|
||||
|
||||
class PydanticAIStreamRunEvent(BaseRunEvent):
|
||||
"""Pydantic AI stream event using the upstream typed event model."""
|
||||
"""Pydantic AI stream event with optional Dify Agent semantic annotations."""
|
||||
|
||||
type: Literal["pydantic_ai_event"] = "pydantic_ai_event"
|
||||
data: AgentStreamEvent
|
||||
agent_message_delta: str | None = None
|
||||
|
||||
|
||||
class RunSucceededEvent(BaseRunEvent):
|
||||
@ -402,7 +404,6 @@ __all__ = [
|
||||
"RunEventsResponse",
|
||||
"RunFailedEvent",
|
||||
"RunFailedEventData",
|
||||
"RunPurpose",
|
||||
"RunStartedEvent",
|
||||
"RunStatus",
|
||||
"RunStatusResponse",
|
||||
|
||||
@ -89,11 +89,22 @@ async def emit_run_started(sink: RunEventSink, *, run_id: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
async def emit_pydantic_ai_event(sink: RunEventSink, *, run_id: str, data: AgentStreamEvent) -> str:
|
||||
async def emit_pydantic_ai_event(
|
||||
sink: RunEventSink,
|
||||
*,
|
||||
run_id: str,
|
||||
data: AgentStreamEvent,
|
||||
agent_message_delta: str | None = None,
|
||||
) -> str:
|
||||
"""Emit one typed Pydantic AI stream event."""
|
||||
return await emit_run_event(
|
||||
sink,
|
||||
event=PydanticAIStreamRunEvent(run_id=run_id, data=data, created_at=utc_now()),
|
||||
event=PydanticAIStreamRunEvent(
|
||||
run_id=run_id,
|
||||
data=data,
|
||||
agent_message_delta=agent_message_delta,
|
||||
created_at=utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,14 +1,21 @@
|
||||
"""Runtime execution for one scheduled Dify Agent run.
|
||||
|
||||
The runner is storage-agnostic: it normalizes the public Dify composition into
|
||||
Agenton's graph/config split, enters a fresh ``CompositorRun`` (or resumes one
|
||||
from a snapshot), renders the current Dify system prompts into temporary
|
||||
``message_history``, runs pydantic-ai with either the current ``run.user_prompts``
|
||||
or deferred external tool results, emits stream events, applies request-level
|
||||
``on_exit`` signals, and then publishes a terminal success or failure event. The
|
||||
Pydantic AI model is resolved from the active Agenton layer named by
|
||||
Agenton's graph/config split and chooses one of two execution modes after the
|
||||
composition is normalized and the ``on_exit`` policy is validated:
|
||||
|
||||
- model runs: enter a fresh ``CompositorRun`` (or resume one from a snapshot),
|
||||
render the current Dify system prompts into temporary ``message_history``, run
|
||||
pydantic-ai with either the current ``run.user_prompts`` or deferred external
|
||||
tool results, emit raw stream events with agent-message delta annotations, apply
|
||||
request-level ``on_exit`` signals, and publish a terminal success or failure event;
|
||||
- lifecycle-only runs: enter from a supplied snapshot, apply request-level
|
||||
``on_exit`` signals, exit without invoking a model, and succeed with explicit
|
||||
``output = null`` and ``usage = null``.
|
||||
|
||||
The Pydantic AI model is resolved from the active Agenton layer named by
|
||||
``DIFY_AGENT_MODEL_LAYER_ID``. An optional history layer contributes stored
|
||||
message history only through session state; successful runs append only
|
||||
message history only through session state; successful model runs append only
|
||||
``result.new_messages()`` back into that layer so current system prompts are not
|
||||
persisted. An optional structured output layer named by
|
||||
``DIFY_AGENT_OUTPUT_LAYER_ID`` is read after entry and resolved into an output
|
||||
@ -31,11 +38,11 @@ from typing import Any, Literal, Protocol, cast, runtime_checkable
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
from pydantic_ai.messages import AgentStreamEvent
|
||||
from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta
|
||||
from pydantic_ai.output import OutputSpec
|
||||
from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerConfigInput, LayerProviderInput
|
||||
from agenton.layers.types import PydanticAITool
|
||||
from dify_agent.layers.ask_human.layer import get_ask_human_layer, validate_ask_human_layer_composition
|
||||
from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer
|
||||
@ -97,6 +104,20 @@ class AgentRunValidationError(ValueError):
|
||||
"""Raised when a run request is valid JSON but cannot execute."""
|
||||
|
||||
|
||||
def _has_model_layer(request: CreateRunRequest) -> bool:
|
||||
"""Return whether the public composition includes the reserved model layer."""
|
||||
return any(layer.name == DIFY_AGENT_MODEL_LAYER_ID for layer in request.composition.layers)
|
||||
|
||||
|
||||
def _extract_agent_message_delta(event: AgentStreamEvent) -> str | None:
|
||||
"""Return agent-message text content from Pydantic AI stream events."""
|
||||
if isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta):
|
||||
return event.delta.content_delta
|
||||
if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart):
|
||||
return event.part.content
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunSuccessOutcome:
|
||||
"""Normalized successful runner output before event emission."""
|
||||
@ -163,7 +184,7 @@ class AgentRunRunner:
|
||||
await self.sink.update_status(self.run_id, "succeeded")
|
||||
|
||||
async def _run_agent(self) -> RunSuccessOutcome:
|
||||
"""Run pydantic-ai inside an entered Agenton run.
|
||||
"""Run the normalized request in model or lifecycle-only mode.
|
||||
|
||||
Known request-shaped Agenton enter-time failures are normalized to
|
||||
``AgentRunValidationError``. That includes the existing small class of
|
||||
@ -190,6 +211,57 @@ class AgentRunRunner:
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise AgentRunValidationError(str(exc)) from exc
|
||||
|
||||
if not _has_model_layer(self.request):
|
||||
return await self._run_lifecycle_only(compositor=compositor, layer_configs=layer_configs)
|
||||
return await self._run_model(compositor=compositor, layer_configs=layer_configs)
|
||||
|
||||
async def _run_lifecycle_only(
|
||||
self,
|
||||
*,
|
||||
compositor: Any,
|
||||
layer_configs: dict[str, LayerConfigInput],
|
||||
) -> RunSuccessOutcome:
|
||||
"""Replay only layer lifecycle work for a no-LLM composition plus snapshot."""
|
||||
if self.request.session_snapshot is None:
|
||||
raise AgentRunValidationError(
|
||||
f"Missing '{DIFY_AGENT_MODEL_LAYER_ID}' requires a session_snapshot for lifecycle-only runs."
|
||||
)
|
||||
if self.request.deferred_tool_results is not None:
|
||||
raise AgentRunValidationError(
|
||||
f"Deferred tool results require the reserved '{DIFY_AGENT_MODEL_LAYER_ID}' layer."
|
||||
)
|
||||
|
||||
entered_run = False
|
||||
try:
|
||||
async with compositor.enter(configs=layer_configs, session_snapshot=self.request.session_snapshot) as run:
|
||||
entered_run = True
|
||||
apply_layer_exit_signals(run, self.request.on_exit)
|
||||
except RuntimeError as exc:
|
||||
if not entered_run and is_agenton_enter_validation_runtime_error(exc):
|
||||
raise AgentRunValidationError(str(exc)) from exc
|
||||
raise
|
||||
except ValueError as exc:
|
||||
if not entered_run:
|
||||
raise AgentRunValidationError(str(exc)) from exc
|
||||
raise
|
||||
|
||||
if run.session_snapshot is None:
|
||||
raise RuntimeError("Agenton run did not produce a session snapshot after exit.")
|
||||
return RunSuccessOutcome(
|
||||
result_kind="output",
|
||||
output=None,
|
||||
deferred_tool_call=None,
|
||||
session_snapshot=run.session_snapshot,
|
||||
usage=None,
|
||||
)
|
||||
|
||||
async def _run_model(
|
||||
self,
|
||||
*,
|
||||
compositor: Any,
|
||||
layer_configs: dict[str, LayerConfigInput],
|
||||
) -> RunSuccessOutcome:
|
||||
"""Run the normal model/deferred-tool path inside an entered Agenton run."""
|
||||
entered_run = False
|
||||
output: JsonValue | None = None
|
||||
deferred_tool_call: DeferredToolCallPayload | None = None
|
||||
@ -206,7 +278,13 @@ class AgentRunRunner:
|
||||
|
||||
async def handle_events(_ctx: object, events: AsyncIterable[AgentStreamEvent]) -> None:
|
||||
async for event in events:
|
||||
_ = await emit_pydantic_ai_event(self.sink, run_id=self.run_id, data=event)
|
||||
text_delta = _extract_agent_message_delta(event)
|
||||
_ = await emit_pydantic_ai_event(
|
||||
self.sink,
|
||||
run_id=self.run_id,
|
||||
data=event,
|
||||
agent_message_delta=text_delta,
|
||||
)
|
||||
|
||||
try:
|
||||
output_contract = resolve_run_output_contract(run)
|
||||
|
||||
@ -242,6 +242,45 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(response.parts[0].part_kind, "text")
|
||||
self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response")
|
||||
|
||||
async def test_request_uses_unique_fallback_ids_for_same_name_tool_calls(self) -> None:
|
||||
messages = [
|
||||
ModelRequest(parts=[UserPromptPart("hello")]),
|
||||
ModelResponse(
|
||||
parts=[
|
||||
ToolCallPart(tool_name="lookup", args={"query": "first"}, tool_call_id=""),
|
||||
ToolCallPart(tool_name="lookup", args={"query": "second"}, tool_call_id=""),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
payload = json.loads(request.content.decode("utf-8"))
|
||||
prompt_messages = payload["data"]["prompt_messages"]
|
||||
tool_calls = prompt_messages[1]["tool_calls"]
|
||||
|
||||
self.assertEqual(tool_calls[0]["id"], "tool-call-0-lookup")
|
||||
self.assertEqual(tool_calls[1]["id"], "tool-call-1-lookup")
|
||||
|
||||
return build_stream_response(*single_text_chunk("adapter response", prompt_tokens=11, completion_tokens=7))
|
||||
|
||||
async with self.mock_daemon_stream(httpx.MockTransport(handler)):
|
||||
adapter = DifyLLMAdapterModel(
|
||||
"demo-model",
|
||||
self.make_provider(),
|
||||
model_provider="openai",
|
||||
credentials={"api_key": "secret"},
|
||||
)
|
||||
|
||||
response = await adapter.request(
|
||||
messages,
|
||||
model_settings=None,
|
||||
model_request_parameters=ModelRequestParameters(),
|
||||
)
|
||||
|
||||
self.assertEqual(response.model_name, "demo-model")
|
||||
self.assertEqual(response.parts[0].part_kind, "text")
|
||||
self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response")
|
||||
|
||||
async def test_request_collapses_text_only_assistant_history_parts_to_string_content(self) -> None:
|
||||
messages = [
|
||||
ModelRequest(parts=[UserPromptPart("initial request")]),
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import stat
|
||||
@ -14,16 +16,22 @@ from dify_agent.agent_stub.cli._drive import (
|
||||
pull_drive_from_environment,
|
||||
push_drive_from_environment,
|
||||
)
|
||||
from dify_agent.agent_stub.cli._files import UploadedToolFileMapping, UploadedToolFileResource
|
||||
from dify_agent.agent_stub.cli._files import UploadedToolFileResource
|
||||
from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubDriveCommitRequest,
|
||||
AgentStubDriveCommitResponse,
|
||||
AgentStubDriveItem,
|
||||
AgentStubFileMapping,
|
||||
AgentStubDriveManifestResponse,
|
||||
)
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({"record_id": record_id}, separators=(",", ":")).encode()).decode()
|
||||
return f"dify-file-ref:{payload}"
|
||||
|
||||
|
||||
def test_list_drive_manifest_from_environment_returns_manifest_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
@ -535,7 +543,7 @@ def test_push_drive_from_environment_commits_single_file(monkeypatch: pytest.Mon
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.upload_tool_file_resource_from_environment",
|
||||
lambda *, path: UploadedToolFileResource(
|
||||
mapping=UploadedToolFileMapping(reference="dify-file-ref:tool-file-1"),
|
||||
mapping=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")),
|
||||
tool_file_id="tool-file-1",
|
||||
),
|
||||
)
|
||||
@ -640,7 +648,10 @@ def test_push_drive_from_environment_kind_skill_standardizes_skill_directory(
|
||||
def fake_upload(*, path: str) -> UploadedToolFileResource:
|
||||
uploaded_paths.append(Path(path).name)
|
||||
return UploadedToolFileResource(
|
||||
mapping=UploadedToolFileMapping(reference=f"dify-file-ref:{Path(path).name}"),
|
||||
mapping=AgentStubFileMapping(
|
||||
transfer_method="tool_file",
|
||||
reference=_reference(Path(path).name),
|
||||
),
|
||||
tool_file_id=Path(path).name,
|
||||
)
|
||||
|
||||
@ -697,7 +708,10 @@ def test_push_drive_from_environment_kind_skill_archive_excludes_transient_entri
|
||||
with ZipFile(path) as archive:
|
||||
archive_entries.extend(sorted(archive.namelist()))
|
||||
return UploadedToolFileResource(
|
||||
mapping=UploadedToolFileMapping(reference=f"dify-file-ref:{Path(path).name}"),
|
||||
mapping=AgentStubFileMapping(
|
||||
transfer_method="tool_file",
|
||||
reference=_reference(Path(path).name),
|
||||
),
|
||||
tool_file_id=Path(path).name,
|
||||
)
|
||||
|
||||
@ -821,7 +835,10 @@ def test_push_drive_from_environment_kind_dir_keeps_user_files_that_skill_packag
|
||||
def fake_upload(*, path: str) -> UploadedToolFileResource:
|
||||
uploaded_paths.append(Path(path).relative_to(root).as_posix())
|
||||
return UploadedToolFileResource(
|
||||
mapping=UploadedToolFileMapping(reference=f"dify-file-ref:{Path(path).name}"),
|
||||
mapping=AgentStubFileMapping(
|
||||
transfer_method="tool_file",
|
||||
reference=_reference(Path(path).name),
|
||||
),
|
||||
tool_file_id=Path(path).name,
|
||||
)
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ from dify_agent.agent_stub.cli._files import (
|
||||
upload_tool_file_resource_from_environment,
|
||||
)
|
||||
from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
@ -48,18 +49,42 @@ def test_upload_file_from_environment_requests_signed_url_and_normalizes_output(
|
||||
"dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync",
|
||||
fake_upload_file_to_signed_url_sync,
|
||||
)
|
||||
captured_download_request: dict[str, object] = {}
|
||||
|
||||
def fake_request_agent_stub_file_download_sync(**kwargs):
|
||||
captured_download_request["file"] = kwargs["file"]
|
||||
return type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 12,
|
||||
"download_url": "https://files.example.com/download",
|
||||
},
|
||||
)()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync",
|
||||
fake_request_agent_stub_file_download_sync,
|
||||
)
|
||||
|
||||
result = upload_file_from_environment(path=str(source))
|
||||
|
||||
assert result.model_dump() == {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": _reference("tool-file-1"),
|
||||
"download_url": "https://files.example.com/download",
|
||||
}
|
||||
assert captured == {
|
||||
"filename": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
"file_bytes": b"report-bytes",
|
||||
}
|
||||
assert captured_download_request["file"] == AgentStubFileMapping(
|
||||
transfer_method="tool_file",
|
||||
reference=_reference("tool-file-1"),
|
||||
)
|
||||
|
||||
|
||||
def test_upload_tool_file_resource_from_environment_preserves_tool_file_id(
|
||||
@ -79,12 +104,17 @@ def test_upload_tool_file_resource_from_environment_preserves_tool_file_id(
|
||||
"dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync",
|
||||
lambda **_kwargs: {"id": "tool-file-1", "reference": _reference("tool-file-1")},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync",
|
||||
lambda **_kwargs: pytest.fail("resource helper must not request download_url"),
|
||||
)
|
||||
|
||||
result = upload_tool_file_resource_from_environment(path=str(source))
|
||||
|
||||
assert result.mapping.model_dump() == {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": _reference("tool-file-1"),
|
||||
"url": None,
|
||||
}
|
||||
assert result.tool_file_id == "tool-file-1"
|
||||
|
||||
@ -187,6 +217,34 @@ def test_upload_file_from_environment_rejects_non_canonical_reference(
|
||||
_ = upload_file_from_environment(path=str(source))
|
||||
|
||||
|
||||
def test_upload_file_from_environment_rejects_missing_download_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = tmp_path / "report.pdf"
|
||||
source.write_bytes(b"report-bytes")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.request_agent_stub_file_upload_sync",
|
||||
lambda **_kwargs: type("Response", (), {"upload_url": "https://files.example.com/upload"})(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync",
|
||||
lambda **_kwargs: {"id": "tool-file-1", "reference": _reference("tool-file-1")},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync",
|
||||
lambda **_kwargs: type(
|
||||
"Response", (), {"filename": "report.pdf", "mime_type": "application/pdf", "size": 12}
|
||||
)(),
|
||||
)
|
||||
|
||||
with pytest.raises(AgentStubTransferError, match="missing download_url"):
|
||||
_ = upload_file_from_environment(path=str(source))
|
||||
|
||||
|
||||
def test_download_file_from_environment_supports_mapping_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@ -9,6 +9,7 @@ import pytest
|
||||
|
||||
from dify_agent.agent_stub.cli._drive import DrivePullResult
|
||||
from dify_agent.agent_stub.cli.main import main
|
||||
from dify_agent.agent_stub.client._errors import AgentStubTransferError
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubConfigFileItemsResponse,
|
||||
AgentStubConfigFileItem,
|
||||
@ -450,6 +451,7 @@ def test_cli_file_upload_prints_uploaded_tool_file_json(
|
||||
{
|
||||
"transfer_method": "tool_file",
|
||||
"reference": _reference(Path(path).name),
|
||||
"download_url": f"https://files.example.com/{Path(path).name}",
|
||||
}
|
||||
)
|
||||
},
|
||||
@ -464,9 +466,31 @@ def test_cli_file_upload_prints_uploaded_tool_file_json(
|
||||
assert json.loads(captured.out) == {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": _reference("report.pdf"),
|
||||
"download_url": "https://files.example.com/report.pdf",
|
||||
}
|
||||
|
||||
|
||||
def test_cli_file_upload_exits_non_zero_without_partial_json_when_download_lookup_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
_patch_cli_module(
|
||||
monkeypatch,
|
||||
"_files_module",
|
||||
upload_file_from_environment=lambda *, path: (_ for _ in ()).throw(
|
||||
AgentStubTransferError("signed file download request failed")
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["file", "upload", "/tmp/report.pdf"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 1
|
||||
assert captured.out == ""
|
||||
assert "signed file download request failed" in captured.err
|
||||
|
||||
|
||||
def test_cli_file_download_prints_saved_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@ -485,14 +509,14 @@ def test_cli_file_download_prints_saved_path(
|
||||
assert captured.out.strip() == "/tmp/report.pdf"
|
||||
|
||||
|
||||
def test_cli_file_download_supports_mapping_json(
|
||||
def test_cli_file_download_rejects_mapping_option(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
called = False
|
||||
|
||||
def fake_download_file_from_environment(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
def fake_download_file_from_environment(**_kwargs):
|
||||
nonlocal called
|
||||
called = True
|
||||
return type("Response", (), {"path": Path("/tmp/inputs/report.pdf")})()
|
||||
|
||||
_patch_cli_module(
|
||||
@ -513,15 +537,8 @@ def test_cli_file_download_supports_mapping_json(
|
||||
]
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 0
|
||||
assert captured_kwargs == {
|
||||
"transfer_method": None,
|
||||
"reference_or_url": None,
|
||||
"mapping": json.dumps({"transfer_method": "tool_file", "reference": _reference("tool-file-1")}),
|
||||
"local_dir": "/tmp/inputs",
|
||||
}
|
||||
assert captured.out.strip() == "/tmp/inputs/report.pdf"
|
||||
assert exc_info.value.code == 2
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_cli_file_download_rejects_legacy_positional_directory(
|
||||
|
||||
@ -259,7 +259,11 @@ def test_sync_sandbox_methods_post_dtos_and_parse_responses() -> None:
|
||||
200,
|
||||
json={
|
||||
"path": "report.txt",
|
||||
"file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "dify-file-ref:file-1",
|
||||
"download_url": "https://files.example.com/report.txt",
|
||||
},
|
||||
},
|
||||
)
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
@ -276,6 +280,7 @@ def test_sync_sandbox_methods_post_dtos_and_parse_responses() -> None:
|
||||
assert preview.text == "hello"
|
||||
assert isinstance(uploaded, SandboxUploadResponse)
|
||||
assert uploaded.file.reference == "dify-file-ref:file-1"
|
||||
assert uploaded.file.download_url == "https://files.example.com/report.txt"
|
||||
|
||||
|
||||
def test_async_sandbox_methods_post_dtos_and_parse_responses() -> None:
|
||||
@ -293,7 +298,11 @@ def test_async_sandbox_methods_post_dtos_and_parse_responses() -> None:
|
||||
200,
|
||||
json={
|
||||
"path": "report.txt",
|
||||
"file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "dify-file-ref:file-1",
|
||||
"download_url": "https://files.example.com/report.txt",
|
||||
},
|
||||
},
|
||||
)
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
@ -309,11 +318,35 @@ def test_async_sandbox_methods_post_dtos_and_parse_responses() -> None:
|
||||
assert listing.path == "."
|
||||
assert preview.text == "hello"
|
||||
assert uploaded.file.reference == "dify-file-ref:file-1"
|
||||
assert uploaded.file.download_url == "https://files.example.com/report.txt"
|
||||
await http_client.aclose()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_sync_upload_sandbox_file_rejects_missing_download_url() -> None:
|
||||
locator = _sandbox_locator()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path != "/sandbox/files/upload":
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"path": "report.txt",
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "dify-file-ref:file-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
||||
|
||||
with pytest.raises(DifyAgentValidationError):
|
||||
_ = client.upload_sandbox_file_sync(locator, "report.txt")
|
||||
|
||||
|
||||
def test_sync_sandbox_methods_map_invalid_json_to_validation_error() -> None:
|
||||
responses = iter([httpx.Response(200, text="not-json"), httpx.Response(404, json={"detail": "missing"})])
|
||||
|
||||
|
||||
@ -9,7 +9,11 @@ import pytest
|
||||
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlProvider
|
||||
from dify_agent.layers.config import DifyConfigLayerConfig
|
||||
from dify_agent.layers.config.layer import DifyConfigLayer, DifyConfigLayerError
|
||||
from dify_agent.layers.config.layer import (
|
||||
DifyConfigLayer,
|
||||
DifyConfigLayerError,
|
||||
_AGENT_FILE_UPLOAD_REPLY_HINT,
|
||||
)
|
||||
from dify_agent.layers.shell import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer
|
||||
|
||||
@ -127,7 +131,19 @@ async def test_on_context_create_computes_runtime_fields_and_pulls_mentioned_ass
|
||||
assert layer.runtime_state.pulled_skill_outputs == {"alpha": "/workspace/.dify_conf/skills/alpha\n# Alpha\nUse it."}
|
||||
assert layer.runtime_state.pulled_file_outputs == {"guide.txt": "/workspace/.dify_conf/files/guide.txt"}
|
||||
assert "dify-agent config note push --help" in layer.runtime_state.config_cli_help
|
||||
assert "dify-agent file upload --help" in layer.runtime_state.config_cli_help
|
||||
assert "dify-agent file download --help" in layer.runtime_state.config_cli_help
|
||||
assert layer.runtime_state.push_spec_json_schema == ""
|
||||
suffix_prompt = layer.build_suffix_prompt()
|
||||
assert suffix_prompt.index("Agent config CLI reference for installed `dify-agent`:") < suffix_prompt.index(
|
||||
"Agent file CLI reference for installed `dify-agent`:"
|
||||
)
|
||||
assert "$ dify-agent file upload --help" in suffix_prompt
|
||||
assert "$ dify-agent file download --help" in suffix_prompt
|
||||
assert suffix_prompt.index("$ dify-agent file upload --help") < suffix_prompt.index(
|
||||
"$ dify-agent file download --help"
|
||||
)
|
||||
assert _AGENT_FILE_UPLOAD_REPLY_HINT in suffix_prompt
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@ -8,7 +8,7 @@ import pytest
|
||||
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlProvider
|
||||
from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig
|
||||
from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError
|
||||
from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError, _AGENT_FILE_UPLOAD_REPLY_HINT
|
||||
from dify_agent.layers.shell import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer
|
||||
|
||||
@ -112,14 +112,18 @@ def test_drive_layer_exposes_agent_stub_cli_usage_suffix_prompt() -> None:
|
||||
layer._agent_stub_cli_help = {
|
||||
"dify-agent file --help": _file_help_output("dify-agent file --help"),
|
||||
"dify-agent file upload --help": _file_help_output("dify-agent file upload --help"),
|
||||
"dify-agent file download --help": _file_help_output("dify-agent file download --help"),
|
||||
}
|
||||
|
||||
assert len(layer.suffix_prompts) == 1
|
||||
prompt = layer.suffix_prompts[0]
|
||||
assert "Other available skills" in prompt
|
||||
assert "other-skill: Other Skill" in prompt
|
||||
assert "Agent Stub file CLI help" in prompt
|
||||
assert "Agent Stub file CLI reference for installed `dify-agent`" in prompt
|
||||
assert "$ dify-agent file upload --help" in prompt
|
||||
assert "$ dify-agent file download --help" in prompt
|
||||
assert prompt.index("$ dify-agent file upload --help") < prompt.index("$ dify-agent file download --help")
|
||||
assert _AGENT_FILE_UPLOAD_REPLY_HINT in prompt
|
||||
assert "dify-agent drive" not in prompt
|
||||
|
||||
|
||||
|
||||
@ -42,7 +42,11 @@ from dify_agent.layers.dify_plugin.configs import (
|
||||
def test_run_event_adapter_round_trips_typed_variants() -> None:
|
||||
events = [
|
||||
RunStartedEvent(run_id="run-1"),
|
||||
PydanticAIStreamRunEvent(run_id="run-1", data=FinalResultEvent(tool_name=None, tool_call_id=None)),
|
||||
PydanticAIStreamRunEvent(
|
||||
run_id="run-1",
|
||||
data=FinalResultEvent(tool_name=None, tool_call_id=None),
|
||||
agent_message_delta="hello",
|
||||
),
|
||||
RunSucceededEvent(
|
||||
run_id="run-1",
|
||||
data=RunSucceededEventData(
|
||||
@ -101,6 +105,7 @@ def test_create_run_request_rejects_old_compositor_payload_and_model_layer_id_is
|
||||
|
||||
def test_protocol_package_no_longer_exports_execution_context_dto() -> None:
|
||||
assert not hasattr(protocol_exports, "ExecutionContext")
|
||||
assert not hasattr(protocol_exports, "RunPurpose")
|
||||
|
||||
|
||||
def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_graph_config() -> None:
|
||||
@ -131,7 +136,6 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_
|
||||
}
|
||||
)
|
||||
request = CreateRunRequest(
|
||||
purpose="workflow_node",
|
||||
idempotency_key="workflow-run-1:node-execution-1",
|
||||
metadata={"source": "unit_test"},
|
||||
composition=RunComposition(
|
||||
@ -177,7 +181,6 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_
|
||||
"invoke_from": "service-api",
|
||||
"trace_id": "trace-1",
|
||||
}
|
||||
assert payload["purpose"] == "workflow_node"
|
||||
assert payload["idempotency_key"] == "workflow-run-1:node-execution-1"
|
||||
assert payload["metadata"] == {"source": "unit_test"}
|
||||
assert payload["composition"]["layers"][0]["config"] == {"prefix": "system", "user": "hello", "suffix": []}
|
||||
@ -456,6 +459,16 @@ def test_create_run_request_rejects_removed_top_level_execution_context() -> Non
|
||||
)
|
||||
|
||||
|
||||
def test_create_run_request_rejects_removed_top_level_purpose() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = CreateRunRequest.model_validate(
|
||||
{
|
||||
"composition": {"layers": []},
|
||||
"purpose": "session_cleanup",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_layer_exit_signals_reject_extra_fields() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = LayerExitSignals.model_validate({"default": "suspend", "unknown": "value"})
|
||||
|
||||
@ -56,6 +56,7 @@ from dify_agent.protocol.schemas import (
|
||||
CreateRunRequest,
|
||||
DeferredToolResultsPayload,
|
||||
LayerExitSignals,
|
||||
PydanticAIStreamRunEvent,
|
||||
RunComposition,
|
||||
RunLayerSpec,
|
||||
RunSucceededEvent,
|
||||
@ -196,6 +197,44 @@ def _request(
|
||||
)
|
||||
|
||||
|
||||
def _lifecycle_only_request(
|
||||
*,
|
||||
on_exit: LayerExitSignals | None = None,
|
||||
session_snapshot: CompositorSessionSnapshot | None = None,
|
||||
deferred_tool_results: DeferredToolResultsPayload | None = None,
|
||||
) -> CreateRunRequest:
|
||||
snapshot = session_snapshot or CompositorSessionSnapshot(
|
||||
layers=[
|
||||
LayerSessionSnapshot(name="prompt", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
|
||||
LayerSessionSnapshot(name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
|
||||
]
|
||||
)
|
||||
return CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
session_snapshot=snapshot,
|
||||
deferred_tool_results=deferred_tool_results,
|
||||
on_exit=on_exit or LayerExitSignals(default=ExitIntent.DELETE),
|
||||
)
|
||||
|
||||
|
||||
def _recursive_output_schema() -> dict[str, object]:
|
||||
return {
|
||||
"type": "object",
|
||||
@ -381,6 +420,8 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa
|
||||
assert "agent_output" not in event_types
|
||||
assert "session_snapshot" not in event_types
|
||||
assert event_types[-1:] == ["run_succeeded"]
|
||||
pydantic_events = [event for event in sink.events["run-1"] if isinstance(event, PydanticAIStreamRunEvent)]
|
||||
assert "".join(event.agent_message_delta or "" for event in pydantic_events) == "done"
|
||||
terminal = sink.events["run-1"][-1]
|
||||
assert isinstance(terminal, RunSucceededEvent)
|
||||
assert terminal.data.output == "done"
|
||||
@ -902,9 +943,15 @@ def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.Mo
|
||||
assert http_client.is_closed is False
|
||||
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
|
||||
|
||||
async def fake_get_tools(self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
async def fake_get_tools(
|
||||
self: DifyPluginToolsLayer,
|
||||
*,
|
||||
http_client: httpx.AsyncClient,
|
||||
dify_api_http_client: httpx.AsyncClient,
|
||||
) -> list[Tool[object]]:
|
||||
assert self.config.tools[0].tool_name == "web_search"
|
||||
assert http_client.is_closed is False
|
||||
assert dify_api_http_client.is_closed is False
|
||||
return [Tool(plugin_tool, name="web_search")]
|
||||
|
||||
class FakeResult:
|
||||
@ -1218,8 +1265,14 @@ def test_runner_rejects_duplicate_tool_names_across_dynamic_tool_layers(
|
||||
assert http_client.is_closed is False
|
||||
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
|
||||
|
||||
async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
async def fake_get_tools(
|
||||
_self: DifyPluginToolsLayer,
|
||||
*,
|
||||
http_client: httpx.AsyncClient,
|
||||
dify_api_http_client: httpx.AsyncClient,
|
||||
) -> list[Tool[object]]:
|
||||
assert http_client.is_closed is False
|
||||
assert dify_api_http_client.is_closed is False
|
||||
return [Tool(duplicate_tool, name="shared_tool")]
|
||||
|
||||
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object:
|
||||
@ -1336,8 +1389,14 @@ def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools(
|
||||
assert http_client.is_closed is False
|
||||
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
|
||||
|
||||
async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
async def fake_get_tools(
|
||||
_self: DifyPluginToolsLayer,
|
||||
*,
|
||||
http_client: httpx.AsyncClient,
|
||||
dify_api_http_client: httpx.AsyncClient,
|
||||
) -> list[Tool[object]]:
|
||||
assert http_client.is_closed is False
|
||||
assert dify_api_http_client.is_closed is False
|
||||
return [Tool(dynamic_duplicate_tool, name="web_search")]
|
||||
|
||||
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object:
|
||||
@ -1440,8 +1499,14 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers(
|
||||
assert http_client.is_closed is False
|
||||
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
|
||||
|
||||
async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
async def fake_get_tools(
|
||||
_self: DifyPluginToolsLayer,
|
||||
*,
|
||||
http_client: httpx.AsyncClient,
|
||||
dify_api_http_client: httpx.AsyncClient,
|
||||
) -> list[Tool[object]]:
|
||||
assert http_client.is_closed is False
|
||||
assert dify_api_http_client.is_closed is False
|
||||
|
||||
async def duplicate_shell_run() -> str:
|
||||
return "tool"
|
||||
@ -1483,17 +1548,23 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers(
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="shell",
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyShellLayerConfig(),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
@ -1760,6 +1831,112 @@ def test_runner_applies_on_exit_overrides_to_success_snapshot(monkeypatch: pytes
|
||||
}
|
||||
|
||||
|
||||
def test_runner_lifecycle_only_cleanup_succeeds_without_model_and_emits_no_pydantic_ai_events() -> None:
|
||||
request = _lifecycle_only_request()
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
run_id="run-lifecycle-only",
|
||||
plugin_daemon_http_client=client,
|
||||
dify_api_http_client=client,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
events = sink.events["run-lifecycle-only"]
|
||||
assert [event.type for event in events] == ["run_started", "run_succeeded"]
|
||||
terminal = events[-1]
|
||||
assert isinstance(terminal, RunSucceededEvent)
|
||||
assert terminal.data.output is None
|
||||
assert terminal.data.usage is None
|
||||
assert {layer.name: layer.lifecycle_state for layer in terminal.data.session_snapshot.layers} == {
|
||||
"prompt": LifecycleState.CLOSED,
|
||||
"execution_context": LifecycleState.CLOSED,
|
||||
}
|
||||
|
||||
|
||||
def test_runner_lifecycle_only_requires_session_snapshot() -> None:
|
||||
request = _request(llm_layer_name="not-llm")
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(AgentRunValidationError, match="session_snapshot"):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
run_id="run-lifecycle-only-missing-snapshot",
|
||||
plugin_daemon_http_client=client,
|
||||
dify_api_http_client=client,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [event.type for event in sink.events["run-lifecycle-only-missing-snapshot"]] == ["run_started", "run_failed"]
|
||||
assert sink.statuses["run-lifecycle-only-missing-snapshot"] == "failed"
|
||||
|
||||
|
||||
def test_runner_lifecycle_only_rejects_deferred_tool_results() -> None:
|
||||
request = _lifecycle_only_request(
|
||||
deferred_tool_results=DeferredToolResultsPayload.model_validate({"calls": {"tool-call-1": {"ok": True}}})
|
||||
)
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(AgentRunValidationError, match="Deferred tool results"):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
run_id="run-lifecycle-only-deferred-results",
|
||||
plugin_daemon_http_client=client,
|
||||
dify_api_http_client=client,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [event.type for event in sink.events["run-lifecycle-only-deferred-results"]] == [
|
||||
"run_started",
|
||||
"run_failed",
|
||||
]
|
||||
assert sink.statuses["run-lifecycle-only-deferred-results"] == "failed"
|
||||
|
||||
|
||||
def test_runner_lifecycle_only_exit_hook_failure_emits_run_failed_not_validation_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
request = _lifecycle_only_request()
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
def _explode(_run: object, _signals: LayerExitSignals) -> None:
|
||||
raise RuntimeError("delete hook failed")
|
||||
|
||||
monkeypatch.setattr("dify_agent.runtime.runner.apply_layer_exit_signals", _explode)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(RuntimeError, match="delete hook failed"):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
run_id="run-lifecycle-only-exit-hook-failure",
|
||||
plugin_daemon_http_client=client,
|
||||
dify_api_http_client=client,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [event.type for event in sink.events["run-lifecycle-only-exit-hook-failure"]] == [
|
||||
"run_started",
|
||||
"run_failed",
|
||||
]
|
||||
assert sink.statuses["run-lifecycle-only-exit-hook-failure"] == "failed"
|
||||
|
||||
|
||||
def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_result(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@ -2363,13 +2540,39 @@ def test_runner_fails_blank_string_user_prompt_list() -> None:
|
||||
assert sink.statuses["run-3"] == "failed"
|
||||
|
||||
|
||||
def test_runner_requires_llm_layer_id() -> None:
|
||||
request = _request(llm_layer_name="not-llm")
|
||||
def test_runner_rejects_reserved_llm_layer_name_with_wrong_type() -> None:
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(user="not an llm"),
|
||||
),
|
||||
]
|
||||
),
|
||||
on_exit=LayerExitSignals(),
|
||||
)
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(AgentRunValidationError, match="llm"):
|
||||
with pytest.raises(AgentRunValidationError, match="DifyPluginLLMLayer"):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
|
||||
@ -394,7 +394,7 @@ def test_embedded_scripts_allow_parent_relative_paths(tmp_path: Path) -> None:
|
||||
"import sys",
|
||||
'if sys.argv[1:] != ["file", "upload", "../shared/notes.txt"]:',
|
||||
' raise SystemExit(f"unexpected args: {sys.argv[1:]!r}")',
|
||||
'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref"}))',
|
||||
'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref", "download_url": "https://files.example.com/notes.txt"}))',
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
@ -424,7 +424,11 @@ def test_embedded_scripts_allow_parent_relative_paths(tmp_path: Path) -> None:
|
||||
}
|
||||
assert upload_payload == {
|
||||
"path": "../shared/notes.txt",
|
||||
"file": {"transfer_method": "tool_file", "reference": "file-ref"},
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "file-ref",
|
||||
"download_url": "https://files.example.com/notes.txt",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -448,7 +452,7 @@ def test_embedded_scripts_expand_home_relative_paths(tmp_path: Path) -> None:
|
||||
"import sys",
|
||||
'if sys.argv[1:] != ["file", "upload", "~/shared/notes.txt"]:',
|
||||
' raise SystemExit(f"unexpected args: {sys.argv[1:]!r}")',
|
||||
'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref"}))',
|
||||
'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref", "download_url": "https://files.example.com/notes.txt"}))',
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
@ -474,7 +478,11 @@ def test_embedded_scripts_expand_home_relative_paths(tmp_path: Path) -> None:
|
||||
}
|
||||
assert upload_payload == {
|
||||
"path": "~/shared/notes.txt",
|
||||
"file": {"transfer_method": "tool_file", "reference": "file-ref"},
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "file-ref",
|
||||
"download_url": "https://files.example.com/notes.txt",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -508,7 +516,11 @@ def test_upload_injects_agent_stub_env_and_returns_mapping() -> None:
|
||||
output=_wrap(
|
||||
{
|
||||
"path": "report.txt",
|
||||
"file": {"transfer_method": "tool_file", "reference": "file-ref"},
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "file-ref",
|
||||
"download_url": "https://files.example.com/report.txt",
|
||||
},
|
||||
},
|
||||
noise=True,
|
||||
),
|
||||
@ -519,6 +531,7 @@ def test_upload_injects_agent_stub_env_and_returns_mapping() -> None:
|
||||
|
||||
assert result.file.transfer_method == "tool_file"
|
||||
assert result.file.reference == "file-ref"
|
||||
assert result.file.download_url == "https://files.example.com/report.txt"
|
||||
script_call = _sandbox_python_run_call(client)
|
||||
assert script_call.cwd == "/home/agent-1/workspace/abc12ff"
|
||||
assert script_call.env == {
|
||||
@ -529,6 +542,26 @@ def test_upload_injects_agent_stub_env_and_returns_mapping() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_upload_rejects_missing_download_url_in_shell_payload() -> None:
|
||||
service, _client = _service(
|
||||
lambda script, cwd, env, timeout: _Job(
|
||||
job_id="sandbox-job",
|
||||
output=_wrap(
|
||||
{
|
||||
"path": "report.txt",
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "file-ref",
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(SandboxFileError, match="sandbox command returned invalid payload"):
|
||||
_ = asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt")))
|
||||
|
||||
|
||||
def test_shell_result_details_include_output_metadata_and_tail() -> None:
|
||||
details = _shell_result_details(
|
||||
_complete_result(output="hello", output_complete=False, incomplete_reason="output_limit")
|
||||
@ -572,7 +605,14 @@ def test_read_and_upload_allow_relative_paths(
|
||||
output=_wrap(
|
||||
{"path": expected_path, "size": 5, "truncated": False, "binary": False, "text": "hello"}
|
||||
if isinstance(sandbox_request, SandboxReadRequest)
|
||||
else {"path": expected_path, "file": {"transfer_method": "tool_file", "reference": "file-ref"}}
|
||||
else {
|
||||
"path": expected_path,
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "file-ref",
|
||||
"download_url": "https://files.example.com/report.txt",
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
@ -583,5 +623,6 @@ def test_read_and_upload_allow_relative_paths(
|
||||
else:
|
||||
result = asyncio.run(service.upload_file(sandbox_request))
|
||||
assert result.path == expected_path
|
||||
assert result.file.download_url == "https://files.example.com/report.txt"
|
||||
|
||||
assert expected_command in _sandbox_python_run_call(client).script
|
||||
|
||||
@ -28,7 +28,7 @@ Use tags in three layers:
|
||||
- `@build` — Build mode and Build draft behavior.
|
||||
- `@build-unavailable-resources` — feature-gated Build chat recovery when the user requests unavailable Skills or Tools.
|
||||
- `@files` — Files section upload, display, and fixture behavior.
|
||||
- `@files-limits` — file limit behavior. Multiple-file drop is stable core coverage; format, size, count, and in-progress upload recovery remain feature-gated until their product contracts are stable.
|
||||
- `@files-limits` — file limit behavior. Multiple-file drop is stable core coverage; format and size rejection remain feature-gated until their product contracts are stable.
|
||||
- `@knowledge` — Knowledge Retrieval configuration display, persistence, and reference cleanup.
|
||||
- `@advanced-settings` — Env Editor, Content Moderation, and related Advanced Settings behavior.
|
||||
- `@agent-create` — Agent Roster creation and initial Configure navigation.
|
||||
@ -225,6 +225,6 @@ Order blocked steps by the real owner of the first unresolved condition. If a sc
|
||||
|
||||
Use partial coverage only when current product behavior is intentionally narrower than the written requirement and the test still asserts a real user-visible behavior. Example: Files are currently flat in Agent config files, so the flat Files list can be asserted while tree display remains blocked until product support exists.
|
||||
|
||||
Multiple-file drop is already covered as stable `@core @files-limits` behavior. File format, size, count, and in-progress upload limit cases remain feature-gated until the product exposes stable Agent config file restrictions and user-visible recovery/error states. Do not convert those gated `@files-limits` scenarios to passing tests by relying on default environment behavior; first align the product contract or seed configuration.
|
||||
Multiple-file drop is already covered as stable `@core @files-limits` behavior. File format and size rejection remain feature-gated until the product exposes stable Agent config file restrictions and user-visible error states. Do not convert those gated `@files-limits` scenarios to passing tests by relying on default environment behavior; first align the product contract or seed configuration.
|
||||
|
||||
Do not mark a scenario as complete if it only proves setup state and does not assert the user-visible behavior or persisted product contract required by the case.
|
||||
|
||||
@ -60,19 +60,3 @@ Feature: Agent v2 files
|
||||
And I drop multiple Agent v2 files into the Files upload dialog
|
||||
Then the Agent v2 Files upload dialog should reject the multiple-file drop
|
||||
And I should not see the dropped Agent v2 files in the Files section
|
||||
|
||||
@files-limits @feature-gated
|
||||
Scenario: Agent v2 total file count limits are enforced
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 total file count limits are available
|
||||
And a basic configured Agent v2 test agent has been created via API
|
||||
When I open the Agent v2 configure page
|
||||
Then Agent v2 total file count limits should be available
|
||||
|
||||
@files-limits @feature-gated
|
||||
Scenario: Leaving during Agent v2 file upload keeps a recoverable state
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 in-progress file upload recovery is available
|
||||
And a basic configured Agent v2 test agent has been created via API
|
||||
When I open the Agent v2 configure page
|
||||
Then Agent v2 in-progress file upload recovery should be available
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
@agent-v2 @authenticated @output-variables
|
||||
Feature: Agent v2 output variables
|
||||
@standalone-output-variables @feature-gated
|
||||
Scenario: Standalone Agent configure exposes Output Variables
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 standalone Output Variables are available
|
||||
And a basic configured Agent v2 test agent has been created via API
|
||||
When I open the Agent v2 configure page
|
||||
Then Agent v2 standalone Output Variables should be available
|
||||
|
||||
@core @stable-model
|
||||
Scenario: Workflow Agent v2 output variables persist after refresh
|
||||
Given I am signed in as the default E2E admin
|
||||
@ -63,23 +55,3 @@ Feature: Agent v2 output variables
|
||||
And I open the Agent v2 workflow node panel
|
||||
And I insert a file output reference from the Agent v2 workflow node task editor
|
||||
Then Agent v2 workflow task output reference deletion consistency should be available
|
||||
|
||||
@output-retry-strategy @feature-gated @stable-model
|
||||
Scenario: Workflow Agent v2 output retry strategy can be saved after refresh
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 workflow output retry strategy is available
|
||||
And the Agent Builder stable chat model is available
|
||||
And a workflow app with an Agent v2 node has been created via API
|
||||
When I open the app from the app list
|
||||
And I open the Agent v2 workflow node panel
|
||||
Then Agent v2 workflow output retry strategy should be available
|
||||
|
||||
@output-retry-validation @feature-gated @stable-model
|
||||
Scenario: Workflow Agent v2 output retry count validation is enforced
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 workflow output retry count validation is available
|
||||
And the Agent Builder stable chat model is available
|
||||
And a workflow app with an Agent v2 node has been created via API
|
||||
When I open the app from the app list
|
||||
And I open the Agent v2 workflow node panel
|
||||
Then Agent v2 workflow output retry count validation should be available
|
||||
|
||||
@ -10,14 +10,9 @@ export const agentBuilderTestMaterials = {
|
||||
invalidEnv: 'agent-invalid.env',
|
||||
buildInstruction: 'agent-build-instruction.txt',
|
||||
summarySkill: 'e2e-summary-skill/SKILL.md',
|
||||
countBatch5: 'count_batch_5_valid_files',
|
||||
countBatch6: 'count_batch_6_valid_files',
|
||||
countTotal50: 'count_total_50_valid_files',
|
||||
countTotalExtra1: 'count_total_extra_1_valid_file',
|
||||
} as const
|
||||
|
||||
export const agentBuilderGeneratedTestMaterials = {
|
||||
slowUploadFile: 'agent-slow-upload-file.txt',
|
||||
tooLargeFile: 'agent-too-large-file.txt',
|
||||
} as const
|
||||
|
||||
@ -30,10 +25,3 @@ export const getTooLargeAgentFilePath = () =>
|
||||
sizeBytes: 16 * 1024 * 1024,
|
||||
seedText: 'E2E_TOO_LARGE_FILE_FIXTURE',
|
||||
})
|
||||
|
||||
export const getSlowUploadAgentFilePath = () =>
|
||||
getGeneratedTextMaterialPath({
|
||||
fileName: 'agent-slow-upload-file.txt',
|
||||
sizeBytes: 2 * 1024 * 1024,
|
||||
seedText: 'E2E_SLOW_UPLOAD_FILE_FIXTURE',
|
||||
})
|
||||
|
||||
@ -175,7 +175,20 @@ const expectPageResponseOK = async (response: Response, action: string) => {
|
||||
}
|
||||
|
||||
When('I discard the Agent v2 Build draft', async function (this: DifyWorld) {
|
||||
await this.getPage().getByRole('button', { exact: true, name: 'Discard' }).click()
|
||||
const page = this.getPage()
|
||||
const agentId = getCurrentAgentId(this)
|
||||
|
||||
await page.getByRole('button', { exact: true, name: 'Discard' }).click()
|
||||
const confirmDialog = page.getByRole('alertdialog', { name: 'Clear session and discard changes?' })
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
|
||||
const discardResponsePromise = page.waitForResponse(response => (
|
||||
response.request().method() === 'DELETE'
|
||||
&& new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-draft`)
|
||||
))
|
||||
|
||||
await confirmDialog.getByRole('button', { name: 'Confirm' }).click()
|
||||
await expectPageResponseOK(await discardResponsePromise, 'Discard Agent v2 Build draft')
|
||||
})
|
||||
|
||||
When(
|
||||
|
||||
@ -282,10 +282,13 @@ Then('I should be on the Agent v2 configure page', async function (this: DifyWor
|
||||
|
||||
Then('I should see the Agent v2 configure workspace', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const agentName = this.lastCreatedAgentName
|
||||
if (!agentName)
|
||||
throw new Error('No Agent v2 name found. Create an Agent v2 test agent first.')
|
||||
|
||||
await expect(page.getByRole('region', { name: 'Configure' })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.getByRole('heading', { name: 'Configure' })).toBeVisible()
|
||||
await expect(page.getByText(this.lastCreatedAgentName!)).toBeVisible()
|
||||
await expect(page.getByText(agentName, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
Then(
|
||||
|
||||
@ -140,41 +140,3 @@ Given('Agent v2 oversized file rejection is available', async function (this: Di
|
||||
Then('Agent v2 oversized file rejection should be available', async function (this: DifyWorld) {
|
||||
return skipOversizedFileRejection(this)
|
||||
})
|
||||
|
||||
async function skipTotalFileCountLimits(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Agent v2 total file count limits are not defined for Agent config files in the current product contract.',
|
||||
{
|
||||
owner: 'product',
|
||||
remediation: 'Define the Agent config file total-count limit and user-visible error before enabling this scenario.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given('Agent v2 total file count limits are available', async function (this: DifyWorld) {
|
||||
return skipTotalFileCountLimits(this)
|
||||
})
|
||||
|
||||
Then('Agent v2 total file count limits should be available', async function (this: DifyWorld) {
|
||||
return skipTotalFileCountLimits(this)
|
||||
})
|
||||
|
||||
async function skipInProgressFileUploadRecovery(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Agent v2 in-progress file upload recovery is not stable: the current dialog has no deterministic slow-upload fixture or user-visible navigation guard contract.',
|
||||
{
|
||||
owner: 'product/test-infra',
|
||||
remediation: 'Define upload-in-progress navigation behavior and provide a deterministic slow upload fixture before enabling this scenario.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given('Agent v2 in-progress file upload recovery is available', async function (this: DifyWorld) {
|
||||
return skipInProgressFileUploadRecovery(this)
|
||||
})
|
||||
|
||||
Then('Agent v2 in-progress file upload recovery should be available', async function (this: DifyWorld) {
|
||||
return skipInProgressFileUploadRecovery(this)
|
||||
})
|
||||
|
||||
@ -330,44 +330,6 @@ async function expectAgentTaskOutputReference(
|
||||
await expect(page.getByText(unexpectedName, { exact: true })).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function skipStandaloneOutputVariables(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Standalone Agent Output Variables are not available: output variables currently belong to Workflow Agent v2 nodes.',
|
||||
{
|
||||
owner: 'product',
|
||||
remediation: 'Expose standalone Agent Output Variables or keep this scenario excluded until the product path exists.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given('Agent v2 standalone Output Variables are available', async function (this: DifyWorld) {
|
||||
return skipStandaloneOutputVariables(this)
|
||||
})
|
||||
|
||||
Then('Agent v2 standalone Output Variables should be available', async function (this: DifyWorld) {
|
||||
return skipStandaloneOutputVariables(this)
|
||||
})
|
||||
|
||||
async function skipWorkflowOutputRetryStrategy(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Agent v2 workflow Output Variables retry strategy is not available in the current editor UI.',
|
||||
{
|
||||
owner: 'product',
|
||||
remediation: 'Expose user-visible retry strategy controls before enabling this scenario.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given('Agent v2 workflow output retry strategy is available', async function (this: DifyWorld) {
|
||||
return skipWorkflowOutputRetryStrategy(this)
|
||||
})
|
||||
|
||||
Then('Agent v2 workflow output retry strategy should be available', async function (this: DifyWorld) {
|
||||
return skipWorkflowOutputRetryStrategy(this)
|
||||
})
|
||||
|
||||
async function skipWorkflowTaskOutputReferenceDeletionConsistency(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
@ -392,22 +354,3 @@ Then(
|
||||
return skipWorkflowTaskOutputReferenceDeletionConsistency(this)
|
||||
},
|
||||
)
|
||||
|
||||
async function skipWorkflowOutputRetryCountValidation(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Agent v2 workflow Output Variables retry count validation is not reachable because retry strategy controls are not available in the current editor UI.',
|
||||
{
|
||||
owner: 'product',
|
||||
remediation: 'Expose retry count controls and validation states before enabling this scenario.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given('Agent v2 workflow output retry count validation is available', async function (this: DifyWorld) {
|
||||
return skipWorkflowOutputRetryCountValidation(this)
|
||||
})
|
||||
|
||||
Then('Agent v2 workflow output retry count validation should be available', async function (this: DifyWorld) {
|
||||
return skipWorkflowOutputRetryCountValidation(this)
|
||||
})
|
||||
|
||||
@ -1 +0,0 @@
|
||||
Batch 5 valid file 1 token E2E_BATCH_5_1
|
||||
@ -1 +0,0 @@
|
||||
Batch 5 valid file 2 token E2E_BATCH_5_2
|
||||
@ -1 +0,0 @@
|
||||
Batch 5 valid file 3 token E2E_BATCH_5_3
|
||||
@ -1 +0,0 @@
|
||||
Batch 5 valid file 4 token E2E_BATCH_5_4
|
||||
@ -1 +0,0 @@
|
||||
Batch 5 valid file 5 token E2E_BATCH_5_5
|
||||
@ -1 +0,0 @@
|
||||
Batch 6 valid file 1 token E2E_BATCH_6_1
|
||||
@ -1 +0,0 @@
|
||||
Batch 6 valid file 2 token E2E_BATCH_6_2
|
||||
@ -1 +0,0 @@
|
||||
Batch 6 valid file 3 token E2E_BATCH_6_3
|
||||
@ -1 +0,0 @@
|
||||
Batch 6 valid file 4 token E2E_BATCH_6_4
|
||||
@ -1 +0,0 @@
|
||||
Batch 6 valid file 5 token E2E_BATCH_6_5
|
||||
@ -1 +0,0 @@
|
||||
Batch 6 valid file 6 token E2E_BATCH_6_6
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 01 token E2E_TOTAL_50_01
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 02 token E2E_TOTAL_50_02
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 03 token E2E_TOTAL_50_03
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 04 token E2E_TOTAL_50_04
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 05 token E2E_TOTAL_50_05
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 06 token E2E_TOTAL_50_06
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 07 token E2E_TOTAL_50_07
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 08 token E2E_TOTAL_50_08
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 09 token E2E_TOTAL_50_09
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 10 token E2E_TOTAL_50_10
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 11 token E2E_TOTAL_50_11
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 12 token E2E_TOTAL_50_12
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 13 token E2E_TOTAL_50_13
|
||||
@ -1 +0,0 @@
|
||||
Total 50 valid file 14 token E2E_TOTAL_50_14
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user