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:
yyh 2026-07-08 20:05:04 +08:00 committed by GitHub
parent d67123e5fd
commit 98d9b11f7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
277 changed files with 7444 additions and 2278 deletions

View File

@ -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",

View File

@ -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 [

View File

@ -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,

View 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",
]

View File

@ -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:

View File

@ -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)

View File

@ -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"]

View File

@ -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,
)

View File

@ -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()

View File

@ -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)

View File

@ -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.

View File

@ -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 {})

View File

@ -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),
)

View File

@ -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.1A.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())

View File

@ -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

View File

@ -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 |

View File

@ -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 |

View File

@ -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 |

View File

@ -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})"

View File

@ -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]:

View File

@ -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]:
"""

View File

@ -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(

View 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)

View File

@ -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(

View File

@ -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

View File

@ -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(

View File

@ -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")])

View File

@ -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"

View File

@ -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,

View File

@ -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)

View File

@ -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.

View File

@ -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 = [

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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(

View File

@ -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)

View File

@ -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",

View File

@ -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."
)

View File

@ -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",
},
}

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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:

View File

@ -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:

View 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."
)

View 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

View File

@ -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"]

View File

@ -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"]

View File

@ -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."""

View File

@ -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",

View File

@ -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")

View File

@ -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",

View File

@ -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(),
),
)

View File

@ -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)

View File

@ -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")]),

View File

@ -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,
)

View File

@ -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,

View File

@ -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(

View File

@ -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"})])

View File

@ -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

View File

@ -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

View File

@ -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"})

View File

@ -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,

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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',
})

View File

@ -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(

View File

@ -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(

View File

@ -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)
})

View File

@ -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)
})

View File

@ -1 +0,0 @@
Batch 5 valid file 1 token E2E_BATCH_5_1

View File

@ -1 +0,0 @@
Batch 5 valid file 2 token E2E_BATCH_5_2

View File

@ -1 +0,0 @@
Batch 5 valid file 3 token E2E_BATCH_5_3

View File

@ -1 +0,0 @@
Batch 5 valid file 4 token E2E_BATCH_5_4

View File

@ -1 +0,0 @@
Batch 5 valid file 5 token E2E_BATCH_5_5

View File

@ -1 +0,0 @@
Batch 6 valid file 1 token E2E_BATCH_6_1

View File

@ -1 +0,0 @@
Batch 6 valid file 2 token E2E_BATCH_6_2

View File

@ -1 +0,0 @@
Batch 6 valid file 3 token E2E_BATCH_6_3

View File

@ -1 +0,0 @@
Batch 6 valid file 4 token E2E_BATCH_6_4

View File

@ -1 +0,0 @@
Batch 6 valid file 5 token E2E_BATCH_6_5

View File

@ -1 +0,0 @@
Batch 6 valid file 6 token E2E_BATCH_6_6

View File

@ -1 +0,0 @@
Total 50 valid file 01 token E2E_TOTAL_50_01

View File

@ -1 +0,0 @@
Total 50 valid file 02 token E2E_TOTAL_50_02

View File

@ -1 +0,0 @@
Total 50 valid file 03 token E2E_TOTAL_50_03

View File

@ -1 +0,0 @@
Total 50 valid file 04 token E2E_TOTAL_50_04

View File

@ -1 +0,0 @@
Total 50 valid file 05 token E2E_TOTAL_50_05

View File

@ -1 +0,0 @@
Total 50 valid file 06 token E2E_TOTAL_50_06

View File

@ -1 +0,0 @@
Total 50 valid file 07 token E2E_TOTAL_50_07

View File

@ -1 +0,0 @@
Total 50 valid file 08 token E2E_TOTAL_50_08

View File

@ -1 +0,0 @@
Total 50 valid file 09 token E2E_TOTAL_50_09

View File

@ -1 +0,0 @@
Total 50 valid file 10 token E2E_TOTAL_50_10

View File

@ -1 +0,0 @@
Total 50 valid file 11 token E2E_TOTAL_50_11

View File

@ -1 +0,0 @@
Total 50 valid file 12 token E2E_TOTAL_50_12

View File

@ -1 +0,0 @@
Total 50 valid file 13 token E2E_TOTAL_50_13

View File

@ -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