Compare commits

...
13 Commits
Author SHA1 Message Date
盐粒 YanliGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a048f35099 refactor: introduce agent working environment architecture (#39480)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 13:16:05 +00:00
Yunlu WenandGitHub 22cef67804 chore(deps): bump gitpython from 3.1.54 to 3.1.57 in /api (#39684) 2026-07-28 10:27:16 +00:00
JoelandGitHub 3bc8c69def feat: label agent conversation reset action on hover (#39691) 2026-07-28 10:06:46 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
798e5ed7a7 refactor(web): derive workspace billing from current workspace (#39674)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 09:32:03 +00:00
Escape0707andGitHub eef709e475 ci: enforce strict checks for unit tests (#39682) 2026-07-28 09:10:02 +00:00
Escape0707andGitHub 04158ac8ea ci: replace custom noqa markers with guard ignores (#39679) 2026-07-28 08:52:12 +00:00
JoelandGitHub ae0b66311d feat: add missing plugin installation to agent DSL imports (#39680) 2026-07-28 08:23:42 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>yunlu.wen
b97abe5328 chore: bump nltk from 3.9.4 to 3.10.0 in /api (#39503)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-07-28 08:19:15 +00:00
JyongandGitHub d94314627f ci: wait for KnowledgeFS before deployment (#39677) 2026-07-28 07:12:29 +00:00
ShaktiandGitHub 7ec6a57ddf fix: populate completion_params from model schema defaults in Agent node (#39590) 2026-07-28 07:06:49 +00:00
JoelandGitHub 35b539e35b fix: improve unconfigured agent guidance (#39676) 2026-07-28 07:02:49 +00:00
Asuka MinatoGitHubByron.wangautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
3b040e6bed test: use sqlite3 session in test_web_login (#38725)
Co-authored-by: Byron.wang <byron@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 06:37:45 +00:00
Asuka MinatoandGitHub 9300be03f0 test: use sqlite3 session in test_agent_app_feature_service (#38724) 2026-07-28 06:32:07 +00:00
284 changed files with 17741 additions and 9153 deletions
+43
View File
@@ -1,6 +1,7 @@
name: Deploy Knowledge name: Deploy Knowledge
permissions: permissions:
actions: read
contents: read contents: read
on: on:
@@ -18,6 +19,48 @@ jobs:
github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/konwledge' github.event.workflow_run.head_branch == 'deploy/konwledge'
steps: steps:
- name: Wait for KnowledgeFS CI
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
timeout-minutes: 35
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const workflowId = "knowledge-fs-ci.yml";
const headBranch = context.payload.workflow_run.head_branch;
const headSha = context.payload.workflow_run.head_sha;
const deadline = Date.now() + 30 * 60 * 1000;
const pollIntervalMs = 15 * 1000;
while (Date.now() < deadline) {
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
branch: headBranch,
event: "push",
head_sha: headSha,
per_page: 10,
});
const run = data.workflow_runs[0];
if (!run) {
core.info(`Waiting for ${workflowId} to start for ${headSha}.`);
} else if (run.status !== "completed") {
core.info(`Waiting for ${run.html_url}; current status is ${run.status}.`);
} else if (run.conclusion !== "success") {
throw new Error(
`${workflowId} did not succeed for ${headSha}: ${run.conclusion} (${run.html_url})`,
);
} else {
core.info(`KnowledgeFS CI succeeded for ${headSha}: ${run.html_url}`);
return;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`Timed out waiting for ${workflowId} to succeed for ${headSha}.`);
- name: Deploy to server - name: Deploy to server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with: with:
+1
View File
@@ -63,6 +63,7 @@ jobs:
E2E_ADMIN_PASSWORD: E2eAdmin12345 E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_FORCE_WEB_BUILD: "1" E2E_FORCE_WEB_BUILD: "1"
E2E_INIT_PASSWORD: E2eInit12345 E2E_INIT_PASSWORD: E2eInit12345
E2E_START_AGENT_BACKEND: "1"
run: vp run e2e:full run: vp run e2e:full
- name: Preserve Chromium E2E report and logs - name: Preserve Chromium E2E report and logs
+2 -2
View File
@@ -99,9 +99,9 @@ ENV VIRTUAL_ENV=/app/api/.venv
COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV} COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV}
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}" ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
# Download nltk data
RUN mkdir -p /usr/local/share/nltk_data \ RUN mkdir -p /usr/local/share/nltk_data \
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.download('punkt'); nltk.download('averaged_perceptron_tagger'); nltk.download('stopwords')" \ && NLTK_DATA=/usr/local/share/nltk_data python -m nltk.downloader punkt_tab averaged_perceptron_tagger_eng stopwords \
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.data.find('tokenizers/punkt_tab'); nltk.data.find('taggers/averaged_perceptron_tagger_eng'); nltk.data.find('corpora/stopwords')" \
&& chmod -R 755 /usr/local/share/nltk_data && chmod -R 755 /usr/local/share/nltk_data
ENV TIKTOKEN_CACHE_DIR=/app/api/.tiktoken_cache ENV TIKTOKEN_CACHE_DIR=/app/api/.tiktoken_cache
-12
View File
@@ -5,8 +5,6 @@ API adapters: request building from Dify product concepts, a thin client wrapper
event adaptation for future workflow integration, and deterministic fakes. event adaptation for future workflow integration, and deterministic fakes.
""" """
from dify_agent.protocol import RuntimeLayerSpec, extract_runtime_layer_specs
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
from clients.agent_backend.errors import ( from clients.agent_backend.errors import (
AgentBackendError, AgentBackendError,
@@ -47,11 +45,6 @@ from clients.agent_backend.request_builder import (
AgentBackendWorkflowNodeRunInput, AgentBackendWorkflowNodeRunInput,
redact_for_agent_backend_log, redact_for_agent_backend_log,
) )
from clients.agent_backend.session_cleanup import (
AgentBackendSessionCleanupPayload,
AgentBackendSessionCleanupResult,
cleanup_agent_backend_session,
)
__all__ = [ __all__ = [
"AGENT_SOUL_PROMPT_LAYER_ID", "AGENT_SOUL_PROMPT_LAYER_ID",
@@ -80,8 +73,6 @@ __all__ = [
"AgentBackendRunRequestBuilder", "AgentBackendRunRequestBuilder",
"AgentBackendRunStartedInternalEvent", "AgentBackendRunStartedInternalEvent",
"AgentBackendRunSucceededInternalEvent", "AgentBackendRunSucceededInternalEvent",
"AgentBackendSessionCleanupPayload",
"AgentBackendSessionCleanupResult",
"AgentBackendStreamError", "AgentBackendStreamError",
"AgentBackendStreamInternalEvent", "AgentBackendStreamInternalEvent",
"AgentBackendTransportError", "AgentBackendTransportError",
@@ -90,9 +81,6 @@ __all__ = [
"DifyAgentBackendRunClient", "DifyAgentBackendRunClient",
"FakeAgentBackendRunClient", "FakeAgentBackendRunClient",
"FakeAgentBackendScenario", "FakeAgentBackendScenario",
"RuntimeLayerSpec",
"cleanup_agent_backend_session",
"create_agent_backend_run_client", "create_agent_backend_run_client",
"extract_runtime_layer_specs",
"redact_for_agent_backend_log", "redact_for_agent_backend_log",
] ]
+30 -76
View File
@@ -16,7 +16,6 @@ from collections.abc import Mapping
from typing import ClassVar, Literal from typing import ClassVar, Literal
from agenton.compositor import CompositorSessionSnapshot from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers import ExitIntent from agenton.layers import ExitIntent
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
@@ -37,6 +36,7 @@ from dify_agent.layers.execution_context import (
) )
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.layers.runtime import DIFY_RUNTIME_LAYER_TYPE_ID, DifyRuntimeLayerConfig
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.protocol import ( from dify_agent.protocol import (
DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_HISTORY_LAYER_ID,
@@ -47,7 +47,6 @@ from dify_agent.protocol import (
LayerExitSignals, LayerExitSignals,
RunComposition, RunComposition,
RunLayerSpec, RunLayerSpec,
RuntimeLayerSpec,
) )
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
@@ -56,6 +55,7 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt" WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt" AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_RUNTIME_LAYER_ID = "runtime"
DIFY_CONFIG_LAYER_ID = "config" DIFY_CONFIG_LAYER_ID = "config"
DIFY_DRIVE_LAYER_ID = "drive" DIFY_DRIVE_LAYER_ID = "drive"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools" DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
@@ -66,25 +66,11 @@ DIFY_SHELL_LAYER_ID = "shell"
type AgentConfigVersionKind = Literal["snapshot", "draft", "build_draft"] type AgentConfigVersionKind = Literal["snapshot", "draft", "build_draft"]
def _filter_snapshot_to_specs(
snapshot: CompositorSessionSnapshot,
specs: list[RuntimeLayerSpec],
) -> CompositorSessionSnapshot:
"""Keep only snapshot layers whose names appear in the cleanup spec list.
The agenton compositor rejects a snapshot whose layer-name sequence does
not match the active composition exactly. Cleanup-replay drops plugin
layers, so we must drop the matching snapshot entries here.
"""
kept_names = {spec.name for spec in specs}
filtered_layers: list[LayerSessionSnapshot] = [layer for layer in snapshot.layers if layer.name in kept_names]
if len(filtered_layers) == len(snapshot.layers):
return snapshot
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
def _shell_layer_deps() -> dict[str, str]: def _shell_layer_deps() -> dict[str, str]:
return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} return {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"runtime": DIFY_RUNTIME_LAYER_ID,
}
def _drive_layer_deps() -> dict[str, str]: def _drive_layer_deps() -> dict[str, str]:
@@ -214,6 +200,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
model: AgentBackendModelConfig model: AgentBackendModelConfig
execution_context: DifyExecutionContextLayerConfig execution_context: DifyExecutionContextLayerConfig
backend_binding_ref: str = Field(min_length=1)
workflow_node_job_prompt: str workflow_node_job_prompt: str
user_prompt: str user_prompt: str
agent_soul_prompt: str | None = None agent_soul_prompt: str | None = None
@@ -231,8 +218,8 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
# the Agent Soul configures human involvement; a deferred call ends the run and # the Agent Soul configures human involvement; a deferred call ends the run and
# the workflow pauses via the existing HITL form mechanism (ENG-635). # the workflow pauses via the existing HITL form mechanism (ENG-635).
ask_human_config: DifyAskHumanLayerConfig | None = None ask_human_config: DifyAskHumanLayerConfig | None = None
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend # Inject the sandboxed shell graph. Requires a deployment-selected runtime
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED. # backend plus the product-resolved persistent Binding.
include_shell: bool = False include_shell: bool = False
shell_config: DifyShellLayerConfig | None = None shell_config: DifyShellLayerConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None session_snapshot: CompositorSessionSnapshot | None = None
@@ -240,7 +227,6 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
# (ENG-638). Keyed by the original deferred tool_call_id. # (ENG-638). Keyed by the original deferred tool_call_id.
deferred_tool_results: DeferredToolResultsPayload | None = None deferred_tool_results: DeferredToolResultsPayload | None = None
include_history: bool = True include_history: bool = True
suspend_on_exit: bool = True
metadata: dict[str, JsonValue] = Field(default_factory=dict) metadata: dict[str, JsonValue] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
@@ -264,6 +250,7 @@ class AgentBackendAgentAppRunInput(BaseModel):
model: AgentBackendModelConfig model: AgentBackendModelConfig
execution_context: DifyExecutionContextLayerConfig execution_context: DifyExecutionContextLayerConfig
backend_binding_ref: str = Field(min_length=1)
user_prompt: str user_prompt: str
agent_soul_prompt: str | None = None agent_soul_prompt: str | None = None
agent_config_version_kind: AgentConfigVersionKind = "snapshot" agent_config_version_kind: AgentConfigVersionKind = "snapshot"
@@ -279,8 +266,8 @@ class AgentBackendAgentAppRunInput(BaseModel):
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement (ENG-635). # the Agent Soul configures human involvement (ENG-635).
ask_human_config: DifyAskHumanLayerConfig | None = None ask_human_config: DifyAskHumanLayerConfig | None = None
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend # Inject the sandboxed shell graph. Requires a deployment-selected runtime
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED. # backend plus the product-resolved persistent Binding.
include_shell: bool = False include_shell: bool = False
shell_config: DifyShellLayerConfig | None = None shell_config: DifyShellLayerConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None session_snapshot: CompositorSessionSnapshot | None = None
@@ -288,7 +275,6 @@ class AgentBackendAgentAppRunInput(BaseModel):
# (ENG-638). Keyed by the original deferred tool_call_id. # (ENG-638). Keyed by the original deferred tool_call_id.
deferred_tool_results: DeferredToolResultsPayload | None = None deferred_tool_results: DeferredToolResultsPayload | None = None
include_history: bool = True include_history: bool = True
suspend_on_exit: bool = True
metadata: dict[str, JsonValue] = Field(default_factory=dict) metadata: dict[str, JsonValue] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
@@ -350,6 +336,14 @@ class AgentBackendRunRequestBuilder:
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
) )
if include_shell: if include_shell:
layers.append(
RunLayerSpec(
name=DIFY_RUNTIME_LAYER_ID,
type=DIFY_RUNTIME_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
)
)
# Sandboxed bash workspace (dify.shell). It enters before config/drive # Sandboxed bash workspace (dify.shell). It enters before config/drive
# so eager pulls materialize content in the same filesystem used by # so eager pulls materialize content in the same filesystem used by
# model commands. # model commands.
@@ -481,53 +475,7 @@ class AgentBackendRunRequestBuilder:
metadata=run_input.metadata, metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot, session_snapshot=run_input.session_snapshot,
deferred_tool_results=run_input.deferred_tool_results, deferred_tool_results=run_input.deferred_tool_results,
on_exit=LayerExitSignals( on_exit=LayerExitSignals(default=ExitIntent.SUSPEND),
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),
)
def build_cleanup_request(
self,
*,
session_snapshot: CompositorSessionSnapshot,
runtime_layer_specs: list[RuntimeLayerSpec],
idempotency_key: str | None = None,
metadata: dict[str, JsonValue] | None = None,
) -> CreateRunRequest:
"""Build a lifecycle-only cleanup request that replays the prior layers.
The agenton compositor enforces that the session snapshot's layer names
match the active composition in order, so cleanup must replay the same
non-plugin layer graph that produced the snapshot. Plugin layers
(``dify.plugin.llm``, ``dify.plugin.tools``) are excluded from both the
composition and the snapshot before submission because their configs
may carry credentials or runtime-only declarations that are not
persisted between runs.
"""
if not runtime_layer_specs:
raise ValueError(
"build_cleanup_request requires runtime_layer_specs; an empty "
"composition would fail the agent backend's snapshot validation."
)
request_metadata = dict(metadata or {})
request_metadata["agent_backend_lifecycle"] = "session_cleanup"
layers = [
RunLayerSpec(
name=spec.name,
type=spec.type,
deps=dict(spec.deps),
metadata=dict(spec.metadata),
config=spec.config,
)
for spec in runtime_layer_specs
]
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, runtime_layer_specs)
return CreateRunRequest(
composition=RunComposition(layers=layers),
idempotency_key=idempotency_key,
metadata=request_metadata,
session_snapshot=filtered_snapshot,
on_exit=LayerExitSignals(default=ExitIntent.DELETE),
) )
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest: def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
@@ -580,6 +528,14 @@ class AgentBackendRunRequestBuilder:
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
) )
if include_shell: if include_shell:
layers.append(
RunLayerSpec(
name=DIFY_RUNTIME_LAYER_ID,
type=DIFY_RUNTIME_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
)
)
# Sandboxed bash workspace (dify.shell). It enters before drive so # Sandboxed bash workspace (dify.shell). It enters before drive so
# drive can materialize mentioned targets with `dify-agent drive pull` # drive can materialize mentioned targets with `dify-agent drive pull`
# in the same shell-visible filesystem used by model commands. # in the same shell-visible filesystem used by model commands.
@@ -713,9 +669,7 @@ class AgentBackendRunRequestBuilder:
metadata=run_input.metadata, metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot, session_snapshot=run_input.session_snapshot,
deferred_tool_results=run_input.deferred_tool_results, deferred_tool_results=run_input.deferred_tool_results,
on_exit=LayerExitSignals( on_exit=LayerExitSignals(default=ExitIntent.SUSPEND),
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),
) )
@@ -1,100 +0,0 @@
"""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",
]
+2 -3
View File
@@ -44,9 +44,8 @@ class AgentBackendConfig(BaseSettings):
AGENT_SHELL_ENABLED: bool = Field( AGENT_SHELL_ENABLED: bool = Field(
description=( description=(
"Inject the dify.shell layer (sandboxed bash workspace) into Agent runs. " "Inject the Home, Workspace, Sandbox, and Shell runtime layers into Agent runs. "
"Requires the agent backend to be wired with a shellctl entrypoint before " "Requires Dify Agent to have a deployment-selected runtime backend."
"shell-using Agent runs are executed."
), ),
default=True, default=True,
) )
+1 -1
View File
@@ -52,7 +52,7 @@ def with_session[T, **P, R](
session.commit() session.commit()
return result return result
except Exception: except Exception:
session.rollback() # noqa: no-new-controller-sqlalchemy decorator owns transaction rollback session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback
raise raise
with session_factory.create_session() as session: with session_factory.create_session() as session:
+7 -29
View File
@@ -62,7 +62,7 @@ from libs.datetime_utils import parse_time_range
from libs.helper import dump_response from libs.helper import dump_response
from libs.login import login_required from libs.login import login_required
from models import Account from models import Account
from models.agent import Agent, AgentConfigDraftType, AgentStatus from models.agent import Agent, AgentStatus
from models.agent_config_entities import AgentSoulConfig from models.agent_config_entities import AgentSoulConfig
from models.enums import ApiTokenType from models.enums import ApiTokenType
from models.model import ApiToken, App, IconType from models.model import ApiToken, App, IconType
@@ -265,13 +265,6 @@ class AgentDebugConversationRefreshResponse(BaseModel):
debug_conversation_message_count: int = 0 debug_conversation_message_count: int = 0
class AgentDebugConversationRefreshPayload(BaseModel):
draft_type: AgentConfigDraftType = Field(
default=AgentConfigDraftType.DEBUG_BUILD,
description="Agent draft surface whose conversation should be refreshed",
)
class AgentPublishPayload(BaseModel): class AgentPublishPayload(BaseModel):
version_note: str | None = Field(default=None, description="Optional note for this published Agent version") version_note: str | None = Field(default=None, description="Optional note for this published Agent version")
@@ -315,7 +308,6 @@ register_schema_models(
AgentAppCopyPayload, AgentAppCopyPayload,
AgentPublishPayload, AgentPublishPayload,
AgentBuildDraftCheckoutPayload, AgentBuildDraftCheckoutPayload,
AgentDebugConversationRefreshPayload,
ComposerSavePayload, ComposerSavePayload,
AgentApiStatusPayload, AgentApiStatusPayload,
AgentInviteOptionsQuery, AgentInviteOptionsQuery,
@@ -395,11 +387,10 @@ def _serialize_agent_app_detail(
payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent) payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent)
payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id) payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id)
payload["id"] = agent.id payload["id"] = agent.id
debug_conversation_id = roster_service.get_or_create_agent_app_debug_conversation_id( debug_conversation_id = roster_service.get_or_create_build_conversation(
tenant_id=app_model.tenant_id, tenant_id=app_model.tenant_id,
agent_id=agent.id, agent_id=agent.id,
account_id=current_user.id, account_id=current_user.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
commit=False, commit=False,
) )
message_count = roster_service.count_agent_app_debug_conversation_messages( message_count = roster_service.count_agent_app_debug_conversation_messages(
@@ -439,11 +430,10 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_
tenant_id=tenant_id, tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents_by_app_id.values()], agent_ids=[agent.id for agent in agents_by_app_id.values()],
) )
debug_conversation_ids_by_agent_id = roster_service.load_or_create_agent_app_debug_conversation_ids_by_agent_id( debug_conversation_ids_by_agent_id = roster_service.load_or_create_build_conversation_ids_by_agent_id(
tenant_id=tenant_id, tenant_id=tenant_id,
agents=list(agents_by_app_id.values()), agents=list(agents_by_app_id.values()),
account_id=current_user.id, account_id=current_user.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
) )
payload = AgentAppPagination.model_validate( payload = AgentAppPagination.model_validate(
app_pagination, app_pagination,
@@ -678,16 +668,6 @@ class AgentAppApi(Resource):
@console_ns.route("/agent/<uuid:agent_id>/debug-conversation/refresh") @console_ns.route("/agent/<uuid:agent_id>/debug-conversation/refresh")
class AgentDebugConversationRefreshApi(Resource): class AgentDebugConversationRefreshApi(Resource):
@console_ns.expect(console_ns.models[AgentDebugConversationRefreshPayload.__name__])
@console_ns.doc(
params={
"payload": {
"in": "body",
"required": False,
"schema": {"$ref": f"#/components/schemas/{AgentDebugConversationRefreshPayload.__name__}"},
}
}
)
@console_ns.response( @console_ns.response(
200, 200,
"Agent debug conversation refreshed", "Agent debug conversation refreshed",
@@ -702,12 +682,10 @@ class AgentDebugConversationRefreshApi(Resource):
@with_current_tenant_id @with_current_tenant_id
@with_session @with_session
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID): def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
args = AgentDebugConversationRefreshPayload.model_validate(request.get_json(silent=True) or {}) debug_conversation_id = _agent_roster_service(session).reset_build_conversation(
debug_conversation_id = _agent_roster_service(session).refresh_agent_app_debug_conversation_id(
tenant_id=tenant_id, tenant_id=tenant_id,
agent_id=str(agent_id), agent_id=str(agent_id),
account_id=current_user.id, account_id=current_user.id,
draft_type=args.draft_type,
) )
return AgentDebugConversationRefreshResponse( return AgentDebugConversationRefreshResponse(
debug_conversation_id=debug_conversation_id, debug_conversation_id=debug_conversation_id,
@@ -751,7 +729,7 @@ class AgentBuildDraftCheckoutApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@with_current_user @with_current_user
@with_current_tenant_id @with_current_tenant_id
@with_session @with_session(write=False)
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID): def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {}) args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {})
return AgentComposerService.checkout_agent_app_build_draft( return AgentComposerService.checkout_agent_app_build_draft(
@@ -808,7 +786,7 @@ class AgentBuildDraftApi(Resource):
@edit_permission_required @edit_permission_required
@with_current_user @with_current_user
@with_current_tenant_id @with_current_tenant_id
@with_session @with_session(write=False)
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID): def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
return AgentComposerService.discard_agent_app_build_draft( return AgentComposerService.discard_agent_app_build_draft(
session=session, session=session,
@@ -828,7 +806,7 @@ class AgentBuildDraftApplyApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@with_current_user @with_current_user
@with_current_tenant_id @with_current_tenant_id
@with_session @with_session(write=False)
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID): def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
return AgentComposerService.apply_agent_app_build_draft( return AgentComposerService.apply_agent_app_build_draft(
session=session, session=session,
@@ -1,7 +1,7 @@
"""Console routes for Agent App and workflow Agent sandbox file access. """Console routes for Agent App and workflow Agent sandbox file access.
The API keeps product-facing locators (conversation or workflow node identity) The API accepts product-facing Conversation, Build Draft, or Workflow Node
on this public boundary and proxies list/read/upload to the agent backend's new Execution locators and proxies list/read/upload to the agent backend's
``/sandbox`` contract. ``/sandbox`` contract.
""" """
@@ -26,10 +26,16 @@ from controllers.common.session import with_session
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id from controllers.console.wraps import (
account_initialization_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel from fields.base import ResponseModel
from libs.login import login_required from libs.login import login_required
from models import Account
from models.model import App, AppMode from models.model import App, AppMode
from services.agent_app_sandbox_service import ( from services.agent_app_sandbox_service import (
AgentAppSandboxService, AgentAppSandboxService,
@@ -37,52 +43,43 @@ from services.agent_app_sandbox_service import (
WorkflowAgentSandboxService, WorkflowAgentSandboxService,
) )
_NODE_EXECUTION_ID_DESCRIPTION = (
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
)
class AgentSandboxListQuery(BaseModel): class AgentSandboxListQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID") caller_type: Literal["conversation", "build_draft"]
caller_id: str = Field(min_length=1, description="Agent App caller ID")
path: str = Field(default=".", description="Directory path relative to the sandbox workspace") path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
class AgentSandboxInfoQuery(BaseModel): class AgentSandboxInfoQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID") caller_type: Literal["conversation", "build_draft"]
caller_id: str = Field(min_length=1, description="Agent App caller ID")
class AgentSandboxFileQuery(BaseModel): class AgentSandboxFileQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID") caller_type: Literal["conversation", "build_draft"]
caller_id: str = Field(min_length=1, description="Agent App caller ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace") path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
class AgentSandboxUploadPayload(BaseModel): class AgentSandboxUploadPayload(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID") caller_type: Literal["conversation", "build_draft"]
caller_id: str = Field(min_length=1, description="Agent App caller ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace") path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
class WorkflowAgentSandboxListQuery(BaseModel): class WorkflowAgentSandboxListQuery(BaseModel):
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
path: str = Field(default=".", description="Directory path relative to the sandbox workspace") path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=_NODE_EXECUTION_ID_DESCRIPTION,
)
class WorkflowAgentSandboxFileQuery(BaseModel): class WorkflowAgentSandboxFileQuery(BaseModel):
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace") path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=_NODE_EXECUTION_ID_DESCRIPTION,
)
class WorkflowAgentSandboxUploadPayload(BaseModel): class WorkflowAgentSandboxUploadPayload(BaseModel):
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace") path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=_NODE_EXECUTION_ID_DESCRIPTION,
)
class SandboxFileEntryResponse(ResponseModel): class SandboxFileEntryResponse(ResponseModel):
@@ -99,7 +96,6 @@ class SandboxListResponse(ResponseModel):
class SandboxInfoResponse(ResponseModel): class SandboxInfoResponse(ResponseModel):
session_id: str
workspace_cwd: str workspace_cwd: str
@@ -155,15 +151,19 @@ class AgentAppSandboxInfoResource(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@with_current_tenant_id @with_current_tenant_id
@with_current_user
@with_session(write=False) @with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID): def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxInfoQuery) query = query_params_from_request(AgentSandboxInfoQuery)
try: try:
result = AgentAppSandboxService().get_info( result = AgentAppSandboxService().get_info(
tenant_id=tenant_id, tenant_id=tenant_id,
app_id=app_model.id, app_id=app_model.id,
conversation_id=query.conversation_id, agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
account_id=current_user.id,
) )
except Exception as exc: except Exception as exc:
return _handle(exc) return _handle(exc)
@@ -180,15 +180,19 @@ class AgentAppSandboxListResource(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@with_current_tenant_id @with_current_tenant_id
@with_current_user
@with_session(write=False) @with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID): def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxListQuery) query = query_params_from_request(AgentSandboxListQuery)
try: try:
result = AgentAppSandboxService().list_files( result = AgentAppSandboxService().list_files(
tenant_id=tenant_id, tenant_id=tenant_id,
app_id=app_model.id, app_id=app_model.id,
conversation_id=query.conversation_id, agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
account_id=current_user.id,
path=query.path, path=query.path,
) )
except Exception as exc: except Exception as exc:
@@ -206,15 +210,19 @@ class AgentAppSandboxReadResource(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@with_current_tenant_id @with_current_tenant_id
@with_current_user
@with_session(write=False) @with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID): def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxFileQuery) query = query_params_from_request(AgentSandboxFileQuery)
try: try:
result = AgentAppSandboxService().read_file( result = AgentAppSandboxService().read_file(
tenant_id=tenant_id, tenant_id=tenant_id,
app_id=app_model.id, app_id=app_model.id,
conversation_id=query.conversation_id, agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
account_id=current_user.id,
path=query.path, path=query.path,
) )
except Exception as exc: except Exception as exc:
@@ -232,15 +240,19 @@ class AgentAppSandboxUploadResource(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@with_current_tenant_id @with_current_tenant_id
@with_current_user
@with_session(write=False) @with_session(write=False)
def post(self, session: Session, tenant_id: str, agent_id: UUID): def post(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {}) payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
try: try:
result = AgentAppSandboxService().upload_file( result = AgentAppSandboxService().upload_file(
tenant_id=tenant_id, tenant_id=tenant_id,
app_id=app_model.id, app_id=app_model.id,
conversation_id=payload.conversation_id, agent_id=str(agent_id),
caller_type=payload.caller_type,
caller_id=payload.caller_id,
account_id=current_user.id,
path=payload.path, path=payload.path,
) )
except Exception as exc: except Exception as exc:
+18 -15
View File
@@ -36,7 +36,7 @@ from controllers.console.wraps import (
with_current_user_id, with_current_user_id,
) )
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.entities.app_invoke_entities import AGENT_RUNTIME_EXIT_INTENT_ARG, InvokeFrom from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
from core.errors.error import ( from core.errors.error import (
ModelCurrentlyNotSupportError, ModelCurrentlyNotSupportError,
@@ -353,12 +353,7 @@ def _resolve_current_user_agent_debug_conversation_id(
draft_type: AgentConfigDraftType, draft_type: AgentConfigDraftType,
start_new: bool = False, start_new: bool = False,
) -> str: ) -> str:
"""Resolve or rotate the current editor's conversation within one draft surface. """Resolve the current editor's Build or Preview conversation."""
``start_new`` rotates the scoped mapping through ``AgentRosterService`` so
the old runtime session is retired before the new conversation is used.
Continuations and Build chat keep resolving the existing mapping.
"""
roster_service = AgentRosterService(session) roster_service = AgentRosterService(session)
resolved_agent_id = agent_id resolved_agent_id = agent_id
@@ -368,17 +363,26 @@ def _resolve_current_user_agent_debug_conversation_id(
raise AgentNotFoundError() raise AgentNotFoundError()
resolved_agent_id = agent.id resolved_agent_id = agent.id
resolve_conversation = ( if draft_type == AgentConfigDraftType.DEBUG_BUILD:
roster_service.refresh_agent_app_debug_conversation_id return roster_service.get_or_create_build_conversation(
if start_new tenant_id=current_tenant_id,
else roster_service.get_or_create_agent_app_debug_conversation_id agent_id=resolved_agent_id,
) account_id=current_user.id,
return resolve_conversation( )
if start_new:
return roster_service.rotate_preview_conversation(
tenant_id=current_tenant_id,
agent_id=resolved_agent_id,
account_id=current_user.id,
)
conversation_id = roster_service.get_current_preview_conversation(
tenant_id=current_tenant_id, tenant_id=current_tenant_id,
agent_id=resolved_agent_id, agent_id=resolved_agent_id,
account_id=current_user.id, account_id=current_user.id,
draft_type=draft_type,
) )
if conversation_id is None:
raise NotFound("Conversation Not Exists.")
return conversation_id
def _create_chat_message( def _create_chat_message(
@@ -450,7 +454,6 @@ def _create_build_chat_finalization_message(
"draft_type": "debug_build", "draft_type": "debug_build",
"conversation_id": debug_conversation_id, "conversation_id": debug_conversation_id,
"auto_generate_name": False, "auto_generate_name": False,
AGENT_RUNTIME_EXIT_INTENT_ARG: "delete",
} }
external_trace_id = get_external_trace_id(request) external_trace_id = get_external_trace_id(request)
if external_trace_id: if external_trace_id:
+14 -2
View File
@@ -76,11 +76,13 @@ from models import Account, App
from models.model import AppMode from models.model import AppMode
from models.workflow import Workflow from models.workflow import Workflow
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.app_generate_service import AppGenerateService from services.app_generate_service import AppGenerateService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError from services.errors.llm import InvokeRateLimitError
from services.workflow_ref_service import WorkflowRefService from services.workflow_ref_service import WorkflowRefService
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -317,7 +319,7 @@ class _WorkflowResponseSource:
self._session = session self._session = session
def __getattr__(self, name: str) -> object: def __getattr__(self, name: str) -> object:
return getattr(self._workflow, name) # noqa: no-new-getattr response adapter delegates model fields return getattr(self._workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
@property @property
def created_by_account(self) -> Account | None: def created_by_account(self) -> Account | None:
@@ -1245,7 +1247,7 @@ class PublishedWorkflowApi(Resource):
workflow_service = WorkflowService() workflow_service = WorkflowService()
with sessionmaker(db.engine).begin() as session: with sessionmaker(db.engine).begin() as session:
workflow = workflow_service.publish_workflow( workflow, retirement_candidates = workflow_service.publish_workflow(
session=session, session=session,
app_model=app_model, app_model=app_model,
account=current_user, account=current_user,
@@ -1262,6 +1264,16 @@ class PublishedWorkflowApi(Resource):
workflow_created_at = TimestampField().format(workflow.created_at) workflow_created_at = TimestampField().format(workflow.created_at)
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=app_model.tenant_id,
agent_ids=retirement_candidates,
account_id=current_user.id,
)
enqueue_agent_resource_collection(
tenant_id=app_model.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return { return {
"result": "success", "result": "success",
"created_at": workflow_created_at, "created_at": workflow_created_at,
+2 -2
View File
@@ -218,7 +218,7 @@ class _DatasetQueryResponseSource:
return self.query.get_queries(session=self.session) return self.query.get_queries(session=self.session)
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
return getattr(self.query, name) # noqa: no-new-getattr response adapter delegates model fields return getattr(self.query, name) # guard-ignore: no-new-getattr -- delegates model fields
class DatasetQueryListResponse(ResponseModel): class DatasetQueryListResponse(ResponseModel):
@@ -257,7 +257,7 @@ class _RelatedAppResponseSource:
return self.app.mode_compatible_with_agent_with_session(session=self.session) return self.app.mode_compatible_with_agent_with_session(session=self.session)
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
return getattr(self.app, name) # noqa: no-new-getattr response adapter delegates model fields return getattr(self.app, name) # guard-ignore: no-new-getattr -- delegates model fields
class RelatedAppListResponse(ResponseModel): class RelatedAppListResponse(ResponseModel):
+1 -1
View File
@@ -106,7 +106,7 @@ class ExternalKnowledgeApiResponseSource:
return self.external_knowledge_api.get_dataset_bindings(session=self.session) return self.external_knowledge_api.get_dataset_bindings(session=self.session)
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
return getattr(self.external_knowledge_api, name) # noqa: no-new-getattr response adapter delegates model fields return getattr(self.external_knowledge_api, name) # guard-ignore: no-new-getattr -- delegates model fields
def external_knowledge_api_response( def external_knowledge_api_response(
+1 -1
View File
@@ -404,7 +404,7 @@ class TrialWorkflowResponseSource:
return self.workflow.get_tool_published(session=self.session) return self.workflow.get_tool_published(session=self.session)
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
return getattr(self.workflow, name) # noqa: no-new-getattr response adapter delegates model fields return getattr(self.workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
register_schema_models( register_schema_models(
@@ -56,10 +56,12 @@ from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models import Account from models import Account
from models.snippet import CustomizedSnippet from models.snippet import CustomizedSnippet
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.agent.workflow_publish_service import WorkflowAgentPublishService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.snippet_generate_service import SnippetGenerateService from services.snippet_generate_service import SnippetGenerateService
from services.snippet_service import SnippetService from services.snippet_service import SnippetService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -295,8 +297,9 @@ class SnippetPublishedWorkflowApi(Resource):
with Session(db.engine) as session: with Session(db.engine) as session:
snippet = session.merge(snippet) snippet = session.merge(snippet)
tenant_id = snippet.tenant_id
try: try:
workflow = snippet_service.publish_workflow( workflow, retirement_candidates = snippet_service.publish_workflow(
session=session, session=session,
snippet=snippet, snippet=snippet,
account=current_user, account=current_user,
@@ -306,6 +309,16 @@ class SnippetPublishedWorkflowApi(Resource):
except ValueError as e: except ValueError as e:
return {"message": str(e)}, 400 return {"message": str(e)}, 400
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=tenant_id,
agent_ids=retirement_candidates,
account_id=current_user.id,
)
enqueue_agent_resource_collection(
tenant_id=tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return { return {
"result": "success", "result": "success",
"created_at": workflow_created_at, "created_at": workflow_created_at,
+14 -2
View File
@@ -16,6 +16,7 @@ from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
from core.app.entities.app_invoke_entities import ( from core.app.entities.app_invoke_entities import (
AdvancedChatAppGenerateEntity, AdvancedChatAppGenerateEntity,
AppGenerateEntity, AppGenerateEntity,
DifyRunContext,
InvokeFrom, InvokeFrom,
) )
from core.app.entities.queue_entities import ( from core.app.entities.queue_entities import (
@@ -31,7 +32,7 @@ from core.moderation.base import ModerationError
from core.moderation.input_moderation import InputModeration from core.moderation.input_moderation import InputModeration
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.node_factory import get_default_root_node_id from core.workflow.node_factory import get_default_root_node_id
from core.workflow.nodes.agent_v2.session_cleanup_layer import build_workflow_agent_session_cleanup_layer from core.workflow.nodes.agent_v2.workspace_retirement_layer import build_workflow_agent_workspace_retirement_layer
from core.workflow.system_variables import ( from core.workflow.system_variables import (
build_bootstrap_variables, build_bootstrap_variables,
build_system_variables, build_system_variables,
@@ -267,7 +268,18 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
) )
workflow_entry.graph_engine.layer(persistence_layer) workflow_entry.graph_engine.layer(persistence_layer)
workflow_entry.graph_engine.layer(build_workflow_agent_session_cleanup_layer()) workflow_entry.graph_engine.layer(
build_workflow_agent_workspace_retirement_layer(
dify_run_context=DifyRunContext(
tenant_id=self._workflow.tenant_id,
app_id=self._workflow.app_id,
user_id=self.application_generate_entity.user_id,
user_from=user_from,
invoke_from=invoke_from,
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
)
)
conversation_variable_layer = ConversationVariablePersistenceLayer( conversation_variable_layer = ConversationVariablePersistenceLayer(
ConversationVariableUpdater(session_factory.get_session_maker()) ConversationVariableUpdater(session_factory.get_session_maker())
) )
+137 -68
View File
@@ -33,15 +33,13 @@ from core.app.apps.agent_app.app_runner import AgentAppRunner
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore from core.app.apps.agent_app.session_store import AgentAppWorkspaceStore
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
from core.app.entities.app_invoke_entities import ( from core.app.entities.app_invoke_entities import (
AGENT_RUNTIME_EXIT_INTENT_ARG,
AgentAppGenerateEntity, AgentAppGenerateEntity,
AgentRuntimeExitIntent,
DifyRunContext, DifyRunContext,
InvokeFrom, InvokeFrom,
UserFrom, UserFrom,
@@ -51,19 +49,24 @@ from core.db.session_factory import session_factory
from core.ops.ops_trace_manager import TraceQueueManager from core.ops.ops_trace_manager import TraceQueueManager
from core.workflow.file_reference import build_file_reference, is_canonical_file_reference from core.workflow.file_reference import build_file_reference, is_canonical_file_reference
from extensions.ext_database import db from extensions.ext_database import db
from models import Account, App, AppModelConfig, EndUser, Message, MessageAnnotation from models import Account, App, AppModelConfig, Conversation, EndUser, Message, MessageAnnotation
from models.agent import ( from models.agent import (
APP_BACKED_AGENT_SOURCES, APP_BACKED_AGENT_SOURCES,
Agent, Agent,
AgentConfigDraft, AgentConfigDraft,
AgentConfigDraftType, AgentConfigDraftType,
AgentConfigSnapshot, AgentConfigSnapshot,
AgentConfigVersionKind,
AgentScope, AgentScope,
AgentSource, AgentSource,
AgentStatus, AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
) )
from models.agent_config_entities import AgentSoulConfig from models.agent_config_entities import AgentSoulConfig
from models.model import load_annotation_reply_config from models.model import load_annotation_reply_config
from services.agent.workspace_service import AgentWorkspaceService, WorkspaceOwnerScope
from services.conversation_service import ConversationService from services.conversation_service import ConversationService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -150,19 +153,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
inputs = args["inputs"] inputs = args["inputs"]
prompt_file_mappings = args.get("files") or [] prompt_file_mappings = args.get("files") or []
# Resolve the bound roster Agent + its current Agent Soul snapshot.
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,
session=session,
)
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
invoke_from=invoke_from,
snapshot_id=agent_config_id,
)
conversation = None conversation = None
conversation_id = args.get("conversation_id") conversation_id = args.get("conversation_id")
if conversation_id: if conversation_id:
@@ -170,6 +160,21 @@ class AgentAppGenerator(MessageBasedAppGenerator):
app_model=app_model, conversation_id=conversation_id, user=user, session=session app_model=app_model, conversation_id=conversation_id, user=user, session=session
) )
# New conversations use the current Agent generation. Existing
# conversations use the immutable generation named by their Binding.
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,
session=session,
conversation=conversation,
)
session_scope_config_version_id = self._session_scope_config_version_id(
invoke_from=invoke_from,
config_version_id=agent_config_id,
)
# Build the EasyUI-shaped config from the Agent Soul so the chat pipeline # Build the EasyUI-shaped config from the Agent Soul so the chat pipeline
# can persist usage; the answer itself comes from the agent backend. # can persist usage; the answer itself comes from the agent backend.
app_model_config = ( app_model_config = (
@@ -186,8 +191,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
model_conf = ModelConfigConverter.convert(app_config) model_conf = ModelConfigConverter.convert(app_config)
trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id) 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( application_generate_entity = AgentAppGenerateEntity(
task_id=str(uuid.uuid4()), task_id=str(uuid.uuid4()),
app_config=app_config, app_config=app_config,
@@ -215,8 +218,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
agent_id=agent.id, agent_id=agent.id,
agent_config_snapshot_id=agent_config_id, agent_config_snapshot_id=agent_config_id,
agent_config_version_kind=agent_config_version_kind, agent_config_version_kind=agent_config_version_kind,
agent_runtime_session_snapshot_id=runtime_session_snapshot_id, agent_session_scope_config_version_id=session_scope_config_version_id,
agent_runtime_exit_intent=agent_runtime_exit_intent,
) )
conversation, message = self._init_generate_records( conversation, message = self._init_generate_records(
@@ -265,6 +267,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
app_model: App, app_model: App,
user: Account | EndUser, user: Account | EndUser,
conversation_id: str, conversation_id: str,
form_id: str,
invoke_from: InvokeFrom, invoke_from: InvokeFrom,
session: Session, session: Session,
) -> None: ) -> None:
@@ -279,14 +282,21 @@ class AgentAppGenerator(MessageBasedAppGenerator):
conversation = ConversationService.get_conversation( conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=user, session=session app_model=app_model, conversation_id=conversation_id, user=user, session=session
) )
draft_type, draft_id = self._resolve_resume_draft(
app_model=app_model,
conversation=conversation,
user=user,
form_id=form_id,
session=session,
)
agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent( agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent(
app_model, app_model,
invoke_from=invoke_from, invoke_from=invoke_from,
draft_type=self._resume_draft_type( draft_type=draft_type,
app_model=app_model, conversation=conversation, user=user, session=session draft_id=draft_id,
),
user=user, user=user,
session=session, session=session,
conversation=conversation,
) )
app_model_config = ( app_model_config = (
@@ -384,30 +394,37 @@ class AgentAppGenerator(MessageBasedAppGenerator):
) )
@staticmethod @staticmethod
def _resume_draft_type( def _resolve_resume_draft(
*, app_model: App, conversation: Any, user: Account | EndUser, session: Session *,
) -> str | None: app_model: App,
conversation: Any,
user: Account | EndUser,
form_id: str,
session: Session,
) -> tuple[str | None, str | None]:
if conversation.invoke_from != InvokeFrom.DEBUGGER: if conversation.invoke_from != InvokeFrom.DEBUGGER:
return None return None, None
active_session = AgentAppRuntimeSessionStore().load_active_session_for_conversation( if not isinstance(user, Account):
tenant_id=app_model.tenant_id, return AgentConfigDraftType.DRAFT.value, None
app_id=app_model.id,
conversation_id=conversation.id, build_draft = session.scalar(
) select(AgentConfigDraft)
snapshot_id = active_session.scope.agent_config_snapshot_id if active_session is not None else None .join(
if snapshot_id and isinstance(user, Account): AgentWorkspaceBinding,
draft = session.scalar( AgentWorkspaceBinding.id == AgentConfigDraft.agent_workspace_binding_id,
select(AgentConfigDraft).where(
AgentConfigDraft.tenant_id == app_model.tenant_id,
AgentConfigDraft.id == snapshot_id,
)
) )
if draft is not None: .where(
if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD and draft.account_id == user.id: AgentConfigDraft.tenant_id == app_model.tenant_id,
return AgentConfigDraftType.DEBUG_BUILD.value AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD,
if draft.draft_type == AgentConfigDraftType.DRAFT and draft.account_id is None: AgentConfigDraft.account_id == user.id,
return AgentConfigDraftType.DRAFT.value AgentWorkspaceBinding.tenant_id == app_model.tenant_id,
return AgentConfigDraftType.DRAFT.value AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
AgentWorkspaceBinding.pending_form_id == form_id,
)
)
if build_draft is not None:
return AgentConfigDraftType.DEBUG_BUILD.value, build_draft.id
return AgentConfigDraftType.DRAFT.value, None
def _generate_worker( def _generate_worker(
self, self,
@@ -482,7 +499,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
invoke_from=application_generate_entity.invoke_from, invoke_from=application_generate_entity.invoke_from,
) )
with session_factory.create_session() as session: with session_factory.create_session() as session:
_, _, agent_soul = self._resolve_agent_by_id( agent, config_version, agent_soul = self._resolve_agent_by_id(
tenant_id=app_config.tenant_id, tenant_id=app_config.tenant_id,
agent_id=application_generate_entity.agent_id, agent_id=application_generate_entity.agent_id,
snapshot_id=application_generate_entity.agent_config_snapshot_id, snapshot_id=application_generate_entity.agent_config_snapshot_id,
@@ -496,13 +513,18 @@ class AgentAppGenerator(MessageBasedAppGenerator):
agent_config_snapshot_id=application_generate_entity.agent_config_snapshot_id, agent_config_snapshot_id=application_generate_entity.agent_config_snapshot_id,
agent_config_version_kind=application_generate_entity.agent_config_version_kind, agent_config_version_kind=application_generate_entity.agent_config_version_kind,
agent_soul=agent_soul, agent_soul=agent_soul,
home_snapshot_id=config_version.home_snapshot_id,
conversation_id=conversation.id, conversation_id=conversation.id,
query=query, query=query,
message_id=message.id, message_id=message.id,
model_name=application_generate_entity.model_conf.model, model_name=application_generate_entity.model_conf.model,
queue_manager=queue_manager, queue_manager=queue_manager,
session_scope_snapshot_id=application_generate_entity.agent_runtime_session_snapshot_id, session_scope_snapshot_id=application_generate_entity.agent_session_scope_config_version_id,
agent_runtime_exit_intent=application_generate_entity.agent_runtime_exit_intent, build_draft_id=(
application_generate_entity.agent_config_snapshot_id
if application_generate_entity.agent_config_version_kind == AgentConfigVersionKind.BUILD_DRAFT
else None
),
) )
except GenerateTaskStoppedError: except GenerateTaskStoppedError:
pass pass
@@ -519,18 +541,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
raise AgentAppGeneratorError("query is required") raise AgentAppGeneratorError("query is required")
return query.replace("\x00", "") 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 @staticmethod
def _build_runner(dify_context: DifyRunContext) -> AgentAppRunner: def _build_runner(dify_context: DifyRunContext) -> AgentAppRunner:
credentials_provider, _ = build_dify_model_access(dify_context) credentials_provider, _ = build_dify_model_access(dify_context)
@@ -546,7 +556,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
stream_run_timeout_seconds=dify_config.AGENT_BACKEND_RUN_TIMEOUT_SECONDS, stream_run_timeout_seconds=dify_config.AGENT_BACKEND_RUN_TIMEOUT_SECONDS,
), ),
event_adapter=AgentBackendRunEventAdapter(), event_adapter=AgentBackendRunEventAdapter(),
session_store=AgentAppRuntimeSessionStore(), session_store=AgentAppWorkspaceStore(),
text_delta_debounce_seconds=dify_config.AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS, text_delta_debounce_seconds=dify_config.AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS,
) )
@@ -610,8 +620,10 @@ class AgentAppGenerator(MessageBasedAppGenerator):
*, *,
invoke_from: InvokeFrom, invoke_from: InvokeFrom,
draft_type: Any, draft_type: Any,
draft_id: str | None = None,
user: Account | EndUser, user: Account | EndUser,
session: Session, session: Session,
conversation: Conversation | None = None,
) -> tuple[Agent, str, Literal["snapshot", "draft", "build_draft"], AgentSoulConfig]: ) -> tuple[Agent, str, Literal["snapshot", "draft", "build_draft"], AgentSoulConfig]:
agent = session.scalar( agent = session.scalar(
select(Agent) select(Agent)
@@ -643,6 +655,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
tenant_id=app_model.tenant_id, tenant_id=app_model.tenant_id,
agent=agent, agent=agent,
draft_type=draft_type, draft_type=draft_type,
draft_id=draft_id,
account_id=user.id if isinstance(user, Account) else None, account_id=user.id if isinstance(user, Account) else None,
session=session, session=session,
) )
@@ -655,28 +668,82 @@ class AgentAppGenerator(MessageBasedAppGenerator):
# Public runtime must keep serving the active snapshot even when unpublished draft edits exist. # Public runtime must keep serving the active snapshot even when unpublished draft edits exist.
if not agent.active_config_snapshot_id: if not agent.active_config_snapshot_id:
raise AgentAppNotPublishedError("Agent has not been published") raise AgentAppNotPublishedError("Agent has not been published")
conversation_binding = self._resolve_conversation_binding(
session=session,
tenant_id=app_model.tenant_id,
app_id=app_model.id,
agent_id=agent.id,
conversation=conversation,
)
snapshot_id = (
conversation_binding.agent_config_version_id
if conversation_binding is not None
else agent.active_config_snapshot_id
)
_, snapshot, agent_soul = self._resolve_agent_by_id( _, snapshot, agent_soul = self._resolve_agent_by_id(
tenant_id=app_model.tenant_id, tenant_id=app_model.tenant_id,
agent_id=agent.id, agent_id=agent.id,
snapshot_id=agent.active_config_snapshot_id, snapshot_id=snapshot_id,
session=session, session=session,
) )
if conversation_binding is not None:
AgentWorkspaceService.validate_binding_generation(
conversation_binding,
base_home_snapshot_id=snapshot.home_snapshot_id,
agent_config_version_id=snapshot.id,
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
)
return agent, snapshot.id, "snapshot", agent_soul return agent, snapshot.id, "snapshot", agent_soul
@staticmethod @staticmethod
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None: def _resolve_conversation_binding(
"""Return the session scope snapshot id for Agent App runtime state. *,
session: Session,
tenant_id: str,
app_id: str,
agent_id: str,
conversation: Conversation | None,
) -> AgentWorkspaceBinding | None:
"""Resolve the exact participant generation owned by an existing conversation."""
if conversation is None or conversation.agent_workspace_binding_id is None:
return None
binding = AgentWorkspaceService.get_active_binding(
session=session,
tenant_id=tenant_id,
binding_id=conversation.agent_workspace_binding_id,
expected_owner_scope=WorkspaceOwnerScope(
tenant_id=tenant_id,
app_id=app_id,
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id=conversation.id,
),
)
if binding is None or binding.agent_id != agent_id:
raise AgentAppGeneratorError("Conversation participant Binding is unavailable")
return binding
@staticmethod
def _session_scope_config_version_id(*, invoke_from: InvokeFrom, config_version_id: str) -> str | None:
"""Return the config version id that scopes Agent App session reuse.
Console preview/debug chat uses a stable Agent draft row id; build mode Console preview/debug chat uses a stable Agent draft row id; build mode
uses the current user's build-draft row id. Published/web/API runs use uses the current user's build-draft row id. Published/web/API runs use
immutable published snapshot ids. This keeps runtime session continuity immutable published snapshot ids. This keeps Workspace Binding continuity
inside one editable surface without mixing draft/build/published state. inside one editable surface without mixing draft/build/published state.
""" """
return snapshot_id del invoke_from
return config_version_id
@staticmethod @staticmethod
def _resolve_debug_draft( def _resolve_debug_draft(
*, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None, session: Session *,
tenant_id: str,
agent: Agent,
draft_type: Any,
account_id: str | None,
session: Session,
draft_id: str | None = None,
) -> AgentConfigDraft: ) -> AgentConfigDraft:
effective_draft_type = ( effective_draft_type = (
AgentConfigDraftType.DEBUG_BUILD AgentConfigDraftType.DEBUG_BUILD
@@ -700,6 +767,8 @@ class AgentAppGenerator(MessageBasedAppGenerator):
AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD, AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD,
AgentConfigDraft.account_id == account_id, AgentConfigDraft.account_id == account_id,
) )
if draft_id is not None:
stmt = stmt.where(AgentConfigDraft.id == draft_id)
draft = session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) draft = session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
if draft is not None: if draft is not None:
return draft return draft
+45 -143
View File
@@ -3,8 +3,7 @@
Unlike the legacy ``AgentChatAppRunner`` (which runs an in-process ReAct loop), Unlike the legacy ``AgentChatAppRunner`` (which runs an in-process ReAct loop),
this runner delegates to the Agent backend, consumes the streamed event flow, this runner delegates to the Agent backend, consumes the streamed event flow,
republishes the assistant answer through the existing EasyUI chat task republishes the assistant answer through the existing EasyUI chat task
pipeline, and then either saves or retires the conversation-owned runtime pipeline, and saves the latest Agenton snapshot on the persistent Binding.
session depending on the turn's exit policy.
""" """
from __future__ import annotations from __future__ import annotations
@@ -31,22 +30,20 @@ from clients.agent_backend import (
AgentBackendRunFailedInternalEvent, AgentBackendRunFailedInternalEvent,
AgentBackendRunSucceededInternalEvent, AgentBackendRunSucceededInternalEvent,
AgentBackendStreamInternalEvent, AgentBackendStreamInternalEvent,
extract_runtime_layer_specs,
) )
from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload
from core.app.apps.agent_app.runtime_request_builder import ( from core.app.apps.agent_app.runtime_request_builder import (
AgentAppRuntimeBuildContext, AgentAppRuntimeBuildContext,
AgentAppRuntimeRequest, AgentAppRuntimeRequest,
AgentAppRuntimeRequestBuilder, AgentAppRuntimeRequestBuilder,
) )
from core.app.apps.agent_app.session_store import ( from core.app.apps.agent_app.session_store import (
AgentAppRuntimeSessionStore,
AgentAppSessionScope, AgentAppSessionScope,
AgentAppWorkspaceStore,
StoredAgentAppSession, StoredAgentAppSession,
) )
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import AgentRuntimeExitIntent, DifyRunContext from core.app.entities.app_invoke_entities import DifyRunContext
from core.app.entities.queue_entities import ( from core.app.entities.queue_entities import (
QueueAgentMessageEvent, QueueAgentMessageEvent,
QueueAgentThoughtEvent, QueueAgentThoughtEvent,
@@ -67,10 +64,10 @@ from graphon.model_runtime.errors.invoke import (
InvokeRateLimitError, InvokeRateLimitError,
InvokeServerUnavailableError, InvokeServerUnavailableError,
) )
from models.agent import AgentConfigVersionKind
from models.agent_config_entities import AgentSoulConfig from models.agent_config_entities import AgentSoulConfig
from models.enums import CreatorUserRole from models.enums import CreatorUserRole
from models.model import MessageAgentThought from models.model import MessageAgentThought
from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -620,7 +617,7 @@ class AgentAppRunner:
request_builder: AgentAppRuntimeRequestBuilder, request_builder: AgentAppRuntimeRequestBuilder,
agent_backend_client: AgentBackendRunClient, agent_backend_client: AgentBackendRunClient,
event_adapter: AgentBackendRunEventAdapter, event_adapter: AgentBackendRunEventAdapter,
session_store: AgentAppRuntimeSessionStore, session_store: AgentAppWorkspaceStore,
text_delta_debounce_seconds: float, text_delta_debounce_seconds: float,
) -> None: ) -> None:
self._request_builder = request_builder self._request_builder = request_builder
@@ -637,37 +634,41 @@ class AgentAppRunner:
agent_config_snapshot_id: str, agent_config_snapshot_id: str,
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot", agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
agent_soul: AgentSoulConfig, agent_soul: AgentSoulConfig,
home_snapshot_id: str,
conversation_id: str, conversation_id: str,
query: str, query: str,
message_id: str, message_id: str,
model_name: str, model_name: str,
queue_manager: AppQueueManager, queue_manager: AppQueueManager,
session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID, session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID,
agent_runtime_exit_intent: AgentRuntimeExitIntent = "suspend", build_draft_id: str | None = None,
) -> None: ) -> None:
preserve_session = agent_runtime_exit_intent == "suspend"
scope = self._build_session_scope( scope = self._build_session_scope(
dify_context=dify_context, dify_context=dify_context,
agent_id=agent_id, agent_id=agent_id,
agent_config_snapshot_id=agent_config_snapshot_id, agent_config_snapshot_id=agent_config_snapshot_id,
home_snapshot_id=home_snapshot_id,
conversation_id=conversation_id, conversation_id=conversation_id,
session_scope_snapshot_id=session_scope_snapshot_id, session_scope_snapshot_id=session_scope_snapshot_id,
agent_config_version_kind=AgentConfigVersionKind(agent_config_version_kind),
build_draft_id=build_draft_id,
) )
# ENG-638: if a prior turn paused on ask_human and the form is now answered, # ENG-638: if a prior turn paused on ask_human and the form is now answered,
# resume by threading the human's reply into this run as deferred_tool_results. # resume by threading the human's reply into this run as deferred_tool_results.
stored = self._session_store.load_active_session(scope) stored = self._session_store.load_or_create(scope)
runtime = self._build_runtime( runtime = self._build_runtime(
dify_context=dify_context, dify_context=dify_context,
agent_id=agent_id, agent_id=agent_id,
agent_config_snapshot_id=agent_config_snapshot_id, agent_config_snapshot_id=agent_config_snapshot_id,
agent_config_version_kind=agent_config_version_kind, agent_config_version_kind=agent_config_version_kind,
agent_soul=agent_soul, agent_soul=agent_soul,
binding_id=stored.binding_id,
backend_binding_ref=stored.backend_binding_ref,
conversation_id=conversation_id, conversation_id=conversation_id,
query=query, query=query,
idempotency_key=message_id, idempotency_key=message_id,
stored=stored, stored=stored,
message_id=message_id, message_id=message_id,
suspend_on_exit=preserve_session,
) )
create_response = self._agent_backend_client.create_run(runtime.request) create_response = self._agent_backend_client.create_run(runtime.request)
@@ -681,9 +682,6 @@ class AgentAppRunner:
) )
if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent): 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 # 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. # a conversation-owned HITL form; a form submission resumes the run.
self._pause_for_ask_human( self._pause_for_ask_human(
@@ -703,8 +701,8 @@ class AgentAppRunner:
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent): if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
if isinstance(terminal, AgentBackendRunFailedInternalEvent): if isinstance(terminal, AgentBackendRunFailedInternalEvent):
reason = terminal.reason reason = terminal.reason
if reason == "sandbox_expired": if reason == "binding_lost":
raise AgentBackendError("The agent session sandbox has expired. Please start a new conversation.") raise AgentBackendError("The retained agent working environment is no longer available.")
raise _agent_backend_failure_to_exception(terminal) raise _agent_backend_failure_to_exception(terminal)
raise AgentBackendError("Agent backend run did not complete successfully.") raise AgentBackendError("Agent backend run did not complete successfully.")
@@ -719,38 +717,18 @@ class AgentAppRunner:
message_id, message_id,
exc_info=True, exc_info=True,
) )
if preserve_session: self._publish_terminal_answer(
superseded_sessions = self._load_superseded_sessions(scope=scope) queue_manager=queue_manager,
self._publish_terminal_answer( model_name=model_name,
queue_manager=queue_manager, answer=answer,
model_name=model_name, query=query,
answer=answer, usage=_llm_usage_from_agent_backend(terminal.usage),
query=query, )
usage=_llm_usage_from_agent_backend(terminal.usage), self._save_session(
) scope=scope,
session_saved = self._save_session( binding_id=runtime.binding_id,
scope=scope, snapshot=terminal.session_snapshot,
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( def _build_session_scope(
self, self,
@@ -758,8 +736,11 @@ class AgentAppRunner:
dify_context: DifyRunContext, dify_context: DifyRunContext,
agent_id: str, agent_id: str,
agent_config_snapshot_id: str, agent_config_snapshot_id: str,
home_snapshot_id: str,
conversation_id: str, conversation_id: str,
session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId, session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId,
agent_config_version_kind: AgentConfigVersionKind,
build_draft_id: str | None = None,
) -> AgentAppSessionScope: ) -> AgentAppSessionScope:
if isinstance(session_scope_snapshot_id, _DefaultSessionScopeSnapshotId): if isinstance(session_scope_snapshot_id, _DefaultSessionScopeSnapshotId):
effective_session_scope_snapshot_id: str | None = agent_config_snapshot_id effective_session_scope_snapshot_id: str | None = agent_config_snapshot_id
@@ -770,7 +751,10 @@ class AgentAppRunner:
app_id=dify_context.app_id, app_id=dify_context.app_id,
conversation_id=conversation_id, conversation_id=conversation_id,
agent_id=agent_id, agent_id=agent_id,
agent_config_snapshot_id=effective_session_scope_snapshot_id, agent_config_snapshot_id=effective_session_scope_snapshot_id or agent_config_snapshot_id,
home_snapshot_id=home_snapshot_id,
agent_config_version_kind=agent_config_version_kind,
build_draft_id=build_draft_id,
) )
def _build_runtime( def _build_runtime(
@@ -781,14 +765,15 @@ class AgentAppRunner:
agent_config_snapshot_id: str, agent_config_snapshot_id: str,
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"], agent_config_version_kind: Literal["snapshot", "draft", "build_draft"],
agent_soul: AgentSoulConfig, agent_soul: AgentSoulConfig,
binding_id: str,
backend_binding_ref: str,
conversation_id: str, conversation_id: str,
query: str, query: str,
idempotency_key: str, idempotency_key: str,
stored: StoredAgentAppSession | None, stored: StoredAgentAppSession,
message_id: str | None, message_id: str | None,
suspend_on_exit: bool,
) -> AgentAppRuntimeRequest: ) -> AgentAppRuntimeRequest:
session_snapshot = stored.session_snapshot if stored is not None else None session_snapshot = stored.session_snapshot
deferred_tool_results = ( deferred_tool_results = (
self._resolve_pending_ask_human(stored=stored, dify_context=dify_context, message_id=message_id) self._resolve_pending_ask_human(stored=stored, dify_context=dify_context, message_id=message_id)
if message_id is not None if message_id is not None
@@ -804,9 +789,10 @@ class AgentAppRunner:
conversation_id=conversation_id, conversation_id=conversation_id,
user_query=query, user_query=query,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
binding_id=binding_id,
backend_binding_ref=backend_binding_ref,
session_snapshot=session_snapshot, session_snapshot=session_snapshot,
deferred_tool_results=deferred_tool_results, deferred_tool_results=deferred_tool_results,
suspend_on_exit=suspend_on_exit,
) )
) )
@@ -843,9 +829,8 @@ class AgentAppRunner:
# second run with the human's answer (ENG-637/638 columns, conversation owner). # second run with the human's answer (ENG-637/638 columns, conversation owner).
self._save_session( self._save_session(
scope=scope, scope=scope,
backend_run_id=terminal.run_id, binding_id=runtime.binding_id,
snapshot=terminal.session_snapshot, snapshot=terminal.session_snapshot,
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
pending_form_id=created.form_id, pending_form_id=created.form_id,
pending_tool_call_id=terminal.deferred_tool_call.tool_call_id, pending_tool_call_id=terminal.deferred_tool_call.tool_call_id,
) )
@@ -862,12 +847,12 @@ class AgentAppRunner:
def _resolve_pending_ask_human( def _resolve_pending_ask_human(
self, self,
*, *,
stored: StoredAgentAppSession | None, stored: StoredAgentAppSession,
dify_context: DifyRunContext, dify_context: DifyRunContext,
message_id: str, message_id: str,
) -> DeferredToolResultsPayload | None: ) -> DeferredToolResultsPayload | None:
"""Build deferred_tool_results when a pending ask_human form is answered.""" """Build deferred_tool_results when a pending ask_human form is answered."""
if stored is None or stored.pending_form_id is None or stored.pending_tool_call_id is None: if stored.pending_form_id is None or stored.pending_tool_call_id is None:
return None return None
outcome = resolve_ask_human_form( outcome = resolve_ask_human_form(
form_id=stored.pending_form_id, form_id=stored.pending_form_id,
@@ -1036,18 +1021,16 @@ class AgentAppRunner:
self, self,
*, *,
scope: AgentAppSessionScope, scope: AgentAppSessionScope,
backend_run_id: str, binding_id: str,
snapshot: Any, snapshot: Any,
runtime_layer_specs: Any,
pending_form_id: str | None = None, pending_form_id: str | None = None,
pending_tool_call_id: str | None = None, pending_tool_call_id: str | None = None,
) -> bool: ) -> bool:
try: try:
self._session_store.save_active_snapshot( self._session_store.save_active_snapshot(
scope=scope, scope=scope,
backend_run_id=backend_run_id, binding_id=binding_id,
snapshot=snapshot, snapshot=snapshot,
runtime_layer_specs=runtime_layer_specs,
pending_form_id=pending_form_id, pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id, pending_tool_call_id=pending_tool_call_id,
) )
@@ -1064,87 +1047,6 @@ class AgentAppRunner:
) )
return False 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 @staticmethod
def _terminal_output_to_answer(output: JsonValue) -> str: def _terminal_output_to_answer(output: JsonValue) -> str:
"""Normalize the backend's terminal output to assistant text. """Normalize the backend's terminal output to assistant text.
@@ -70,11 +70,12 @@ class AgentAppRuntimeBuildContext:
conversation_id: str conversation_id: str
user_query: str user_query: str
idempotency_key: str idempotency_key: str
binding_id: str
backend_binding_ref: str
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot" agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot"
session_snapshot: CompositorSessionSnapshot | None = None session_snapshot: CompositorSessionSnapshot | None = None
# ENG-638: set when resuming a chat turn after a submitted ask_human form. # ENG-638: set when resuming a chat turn after a submitted ask_human form.
deferred_tool_results: DeferredToolResultsPayload | None = None deferred_tool_results: DeferredToolResultsPayload | None = None
suspend_on_exit: bool = True
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -82,6 +83,7 @@ class AgentAppRuntimeRequest:
request: CreateRunRequest request: CreateRunRequest
redacted_request: dict[str, Any] redacted_request: dict[str, Any]
metadata: dict[str, Any] metadata: dict[str, Any]
binding_id: str
class AgentAppRuntimeRequestBuilder: class AgentAppRuntimeRequestBuilder:
@@ -160,6 +162,7 @@ class AgentAppRuntimeRequestBuilder:
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value), invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
agent_mode="agent_app", agent_mode="agent_app",
), ),
backend_binding_ref=context.backend_binding_ref,
# ENG-616: expand slash-menu mention tokens to canonical names so # ENG-616: expand slash-menu mention tokens to canonical names so
# no frontend-internal {{#…#}} marker ever reaches the model. # no frontend-internal {{#…#}} marker ever reaches the model.
agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip() agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
@@ -175,13 +178,17 @@ class AgentAppRuntimeRequestBuilder:
shell_config=build_shell_layer_config(agent_soul), shell_config=build_shell_layer_config(agent_soul),
session_snapshot=context.session_snapshot, session_snapshot=context.session_snapshot,
deferred_tool_results=context.deferred_tool_results, deferred_tool_results=context.deferred_tool_results,
suspend_on_exit=context.suspend_on_exit,
idempotency_key=context.idempotency_key, idempotency_key=context.idempotency_key,
metadata=metadata, metadata=metadata,
) )
) )
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request)) redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
return AgentAppRuntimeRequest(request=request, redacted_request=redacted, metadata=metadata) return AgentAppRuntimeRequest(
request=request,
redacted_request=redacted,
metadata=metadata,
binding_id=context.binding_id,
)
def _build_tool_layers( def _build_tool_layers(
self, self,
+130 -209
View File
@@ -1,255 +1,176 @@
"""Conversation-keyed Agent backend session store for the Agent App type. """Persist and resolve the exact participant owned by an Agent App caller."""
Shares the unified ``agent_runtime_sessions`` table with the workflow Agent
Node store, but owns rows with ``owner_type = conversation``: one Agent App
conversation maps to one Agent session, so multi-turn chat re-enters the same
``session_snapshot``. Cross-conversation memory (PRD Global / Per app) is a
phase-2 concern and not modeled here.
"""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from agenton.compositor import CompositorSessionSnapshot from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import RuntimeLayerSpec
from pydantic import TypeAdapter
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from models.agent import ( from models.agent import (
AgentRuntimeSession, AgentConfigDraft,
AgentRuntimeSessionOwnerType, AgentConfigDraftType,
AgentRuntimeSessionStatus, AgentConfigVersionKind,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
)
from models.model import App, Conversation
from services.agent.workspace_service import (
AgentWorkspaceNotFoundError,
AgentWorkspaceService,
WorkspaceOwnerScope,
) )
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
def _serialize_runtime_layer_specs(specs: list[RuntimeLayerSpec]) -> str:
return _RUNTIME_LAYER_SPECS_ADAPTER.dump_json(specs).decode()
def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]:
if not value:
return []
return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class AgentAppSessionScope: class AgentAppSessionScope:
"""Identity of one Agent App conversation session."""
tenant_id: str tenant_id: str
app_id: str app_id: str
conversation_id: str conversation_id: str
agent_id: str agent_id: str
agent_config_snapshot_id: str | None agent_config_snapshot_id: str
home_snapshot_id: str
agent_config_version_kind: AgentConfigVersionKind = AgentConfigVersionKind.SNAPSHOT
build_draft_id: str | None = None
@property
def workspace_owner(self) -> WorkspaceOwnerScope:
owner_type = (
AgentWorkspaceOwnerType.BUILD_DRAFT if self.build_draft_id else AgentWorkspaceOwnerType.CONVERSATION
)
return WorkspaceOwnerScope(
tenant_id=self.tenant_id,
app_id=self.app_id,
owner_type=owner_type,
owner_id=self.build_draft_id or self.conversation_id,
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class StoredAgentAppSession: class StoredAgentAppSession:
"""Persisted Agent App conversation session with reusable runtime specs."""
scope: AgentAppSessionScope scope: AgentAppSessionScope
session_snapshot: CompositorSessionSnapshot binding_id: str
backend_run_id: str | None workspace_id: str
runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list) backend_binding_ref: str
# ENG-635: set while the conversation turn is paused on a dify.ask_human session_snapshot: CompositorSessionSnapshot | None
# deferred call, awaiting a HITL form submission.
pending_form_id: str | None = None pending_form_id: str | None = None
pending_tool_call_id: str | None = None pending_tool_call_id: str | None = None
class AgentAppRuntimeSessionStore: class AgentAppWorkspaceStore:
"""Persists Agent backend session snapshots for Agent App conversations.""" """Resolve Agent App sessions through a caller-owned Binding pointer."""
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None: def load_or_create(self, scope: AgentAppSessionScope) -> StoredAgentAppSession:
stored = self.load_active_session(scope)
return stored.session_snapshot if stored is not None else None
def load_active_session(self, scope: AgentAppSessionScope) -> StoredAgentAppSession | None:
with session_factory.create_session() as session: with session_factory.create_session() as session:
row = session.scalar(self._active_stmt(scope)) caller = self._load_caller(session=session, scope=scope)
if row is None: binding_id = caller.agent_workspace_binding_id
return None if binding_id is None:
return StoredAgentAppSession( binding = AgentWorkspaceService.create_binding(
scope=scope, session=session,
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), scope=scope.workspace_owner,
backend_run_id=row.backend_run_id, agent_id=scope.agent_id,
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs), base_home_snapshot_id=scope.home_snapshot_id,
pending_form_id=row.pending_form_id, agent_config_version_id=scope.agent_config_snapshot_id,
pending_tool_call_id=row.pending_tool_call_id, agent_config_version_kind=scope.agent_config_version_kind,
)
def load_active_session_for_conversation(
self, *, tenant_id: str, app_id: str, conversation_id: str
) -> StoredAgentAppSession | None:
"""Load the latest ACTIVE session for one conversation-level sandbox lookup.
Sandbox inspection only knows the product locator
``tenant_id + app_id + conversation_id``; it does not know which
``agent_id`` or Agent Soul snapshot produced the active shell session.
This method therefore resolves the newest ACTIVE conversation-owned row
for that conversation and returns both the resumable snapshot and the
persisted non-sensitive runtime layer specs needed to build a
``SandboxLocator``.
"""
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:
row = session.scalar(stmt)
if row is None:
return None
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 or "",
),
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),
)
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 caller.agent_workspace_binding_id = binding.id
] session.commit()
else:
binding = self._get_binding(session=session, scope=scope, binding_id=binding_id)
return self._stored(scope, binding)
@staticmethod
def _load_caller(*, session: Session, scope: AgentAppSessionScope) -> Conversation | AgentConfigDraft:
if scope.build_draft_id is not None:
if scope.agent_config_version_kind != AgentConfigVersionKind.BUILD_DRAFT:
raise AgentWorkspaceNotFoundError("Build Draft caller requires build_draft generation")
draft = session.scalar(
select(AgentConfigDraft).where(
AgentConfigDraft.id == scope.build_draft_id,
AgentConfigDraft.tenant_id == scope.tenant_id,
AgentConfigDraft.agent_id == scope.agent_id,
AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD,
)
)
if draft is None:
raise AgentWorkspaceNotFoundError("Build Draft caller is unavailable")
return draft
if scope.agent_config_version_kind == AgentConfigVersionKind.BUILD_DRAFT:
raise AgentWorkspaceNotFoundError("Build Draft caller ID is required")
conversation = session.scalar(
select(Conversation)
.join(App, App.id == Conversation.app_id)
.where(
App.tenant_id == scope.tenant_id,
Conversation.id == scope.conversation_id,
Conversation.app_id == scope.app_id,
Conversation.is_deleted.is_(False),
)
)
if conversation is None:
raise AgentWorkspaceNotFoundError("Conversation caller is unavailable")
return conversation
@staticmethod
def _get_binding(
*,
session: Session,
scope: AgentAppSessionScope,
binding_id: str,
) -> AgentWorkspaceBinding:
binding = AgentWorkspaceService.get_active_binding(
session=session,
tenant_id=scope.tenant_id,
binding_id=binding_id,
expected_owner_scope=scope.workspace_owner,
)
if binding is None or binding.agent_id != scope.agent_id:
raise AgentWorkspaceNotFoundError("Caller participant Binding is unavailable")
AgentWorkspaceService.validate_binding_generation(
binding,
base_home_snapshot_id=scope.home_snapshot_id,
agent_config_version_id=scope.agent_config_snapshot_id,
agent_config_version_kind=scope.agent_config_version_kind,
)
return binding
def save_active_snapshot( def save_active_snapshot(
self, self,
*, *,
scope: AgentAppSessionScope, scope: AgentAppSessionScope,
backend_run_id: str, binding_id: str,
snapshot: CompositorSessionSnapshot | None, snapshot: CompositorSessionSnapshot | None,
runtime_layer_specs: list[RuntimeLayerSpec],
pending_form_id: str | None = None, pending_form_id: str | None = None,
pending_tool_call_id: str | None = None, pending_tool_call_id: str | None = 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: if snapshot is None:
return return
snapshot_json = snapshot.model_dump_json() AgentWorkspaceService.save_binding_session_snapshot(
runtime_layer_specs_json = _serialize_runtime_layer_specs(runtime_layer_specs) tenant_id=scope.tenant_id,
with session_factory.create_session() as session: binding_id=binding_id,
row = session.scalar(self._scope_stmt(scope)) session_snapshot=snapshot.model_dump_json(),
if row is None: pending_form_id=pending_form_id,
row = AgentRuntimeSession( pending_tool_call_id=pending_tool_call_id,
tenant_id=scope.tenant_id, )
app_id=scope.app_id,
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
agent_id=scope.agent_id,
agent_config_snapshot_id=scope.agent_config_snapshot_id,
conversation_id=scope.conversation_id,
backend_run_id=backend_run_id,
session_snapshot=snapshot_json,
composition_layer_specs=runtime_layer_specs_json,
status=AgentRuntimeSessionStatus.ACTIVE,
pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id,
)
session.add(row)
else:
row.backend_run_id = backend_run_id
row.session_snapshot = snapshot_json
row.composition_layer_specs = runtime_layer_specs_json
row.status = AgentRuntimeSessionStatus.ACTIVE
row.cleaned_at = None
# Set (or clear, when omitted) the ask_human pause correlation.
row.pending_form_id = pending_form_id
row.pending_tool_call_id = pending_tool_call_id
session.flush()
other_rows = session.scalars(
select(AgentRuntimeSession).where(
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
AgentRuntimeSession.tenant_id == scope.tenant_id,
AgentRuntimeSession.app_id == scope.app_id,
AgentRuntimeSession.conversation_id == scope.conversation_id,
AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE,
AgentRuntimeSession.id != row.id,
)
).all()
for other_row in other_rows:
other_row.status = AgentRuntimeSessionStatus.CLEANED
other_row.cleaned_at = naive_utc_now()
session.commit()
def mark_cleaned(self, *, scope: AgentAppSessionScope, backend_run_id: str | None = None) -> None:
with session_factory.create_session() as session:
row = session.scalar(self._active_stmt(scope))
if row is None:
return
if backend_run_id is not None:
row.backend_run_id = backend_run_id
row.status = AgentRuntimeSessionStatus.CLEANED
row.cleaned_at = naive_utc_now()
session.commit()
@staticmethod @staticmethod
def _scope_stmt(scope: AgentAppSessionScope): def _stored(scope: AgentAppSessionScope, binding: AgentWorkspaceBinding) -> StoredAgentAppSession:
stmt = select(AgentRuntimeSession).where( snapshot = (
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION, CompositorSessionSnapshot.model_validate_json(binding.session_snapshot)
AgentRuntimeSession.tenant_id == scope.tenant_id, if binding.session_snapshot
AgentRuntimeSession.conversation_id == scope.conversation_id, else None
AgentRuntimeSession.agent_id == scope.agent_id, )
return StoredAgentAppSession(
scope=scope,
binding_id=binding.id,
workspace_id=binding.workspace_id,
backend_binding_ref=binding.backend_binding_ref,
session_snapshot=snapshot,
pending_form_id=binding.pending_form_id,
pending_tool_call_id=binding.pending_tool_call_id,
) )
if scope.agent_config_snapshot_id is None:
return stmt.where(AgentRuntimeSession.agent_config_snapshot_id.is_(None))
return stmt.where(AgentRuntimeSession.agent_config_snapshot_id == scope.agent_config_snapshot_id)
@classmethod
def _active_stmt(cls, scope: AgentAppSessionScope):
return cls._scope_stmt(scope).where(AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE)
__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope", "StoredAgentAppSession"] __all__ = ["AgentAppSessionScope", "AgentAppWorkspaceStore", "StoredAgentAppSession"]
+14 -3
View File
@@ -10,11 +10,11 @@ from core.app.apps.workflow.command_channels import (
CombinedCommandChannel, CombinedCommandChannel,
) )
from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, WorkflowAppGenerateEntity
from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.node_factory import get_default_root_node_id from core.workflow.node_factory import get_default_root_node_id
from core.workflow.nodes.agent_v2.session_cleanup_layer import build_workflow_agent_session_cleanup_layer from core.workflow.nodes.agent_v2.workspace_retirement_layer import build_workflow_agent_workspace_retirement_layer
from core.workflow.snippet_start import get_compatible_start_aliases from core.workflow.snippet_start import get_compatible_start_aliases
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
@@ -197,7 +197,18 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
) )
workflow_entry.graph_engine.layer(persistence_layer) workflow_entry.graph_engine.layer(persistence_layer)
workflow_entry.graph_engine.layer(build_workflow_agent_session_cleanup_layer()) workflow_entry.graph_engine.layer(
build_workflow_agent_workspace_retirement_layer(
dify_run_context=DifyRunContext(
tenant_id=self._workflow.tenant_id,
app_id=self._workflow.app_id,
user_id=self.application_generate_entity.user_id,
user_from=user_from,
invoke_from=invoke_from,
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
)
)
for layer in self._graph_engine_layers: for layer in self._graph_engine_layers:
workflow_entry.graph_engine.layer(layer) workflow_entry.graph_engine.layer(layer)
+3 -10
View File
@@ -15,8 +15,6 @@ if TYPE_CHECKING:
DIFY_RUN_CONTEXT_KEY = "_dify" DIFY_RUN_CONTEXT_KEY = "_dify"
AGENT_RUNTIME_EXIT_INTENT_ARG = "_agent_runtime_exit_intent"
type AgentRuntimeExitIntent = Literal["suspend", "delete"]
class UserFrom(StrEnum): class UserFrom(StrEnum):
@@ -227,12 +225,8 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity):
backend should read from: immutable snapshot, shared draft, or per-user backend should read from: immutable snapshot, shared draft, or per-user
build draft. build draft.
``agent_runtime_session_snapshot_id`` carries the runtime session scope ``agent_session_scope_config_version_id`` identifies the draft or immutable
used to resume or suspend within the same editable config surface. config version whose Workspace Binding should be reused for this session.
``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 ``prompt_file_mappings`` preserves the raw request ``files`` array for the
Agent backend prompt. These references are appended to the backend prompt Agent backend prompt. These references are appended to the backend prompt
@@ -242,8 +236,7 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity):
agent_id: str agent_id: str
agent_config_snapshot_id: str agent_config_snapshot_id: str
agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot" agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot"
agent_runtime_session_snapshot_id: str | None = None agent_session_scope_config_version_id: str | None = None
agent_runtime_exit_intent: AgentRuntimeExitIntent = "suspend"
prompt_file_mappings: Sequence[JsonValue] = Field(default_factory=list) prompt_file_mappings: Sequence[JsonValue] = Field(default_factory=list)
+19 -4
View File
@@ -20,11 +20,13 @@ from core.helper.trace_id_helper import ParentTraceContext
from core.ops.entities.trace_entity import TraceTaskName from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.node_execution_process_data import preserve_workflow_agent_binding_id
from core.workflow.system_variables import SystemVariableKey from core.workflow.system_variables import SystemVariableKey
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
from core.workflow.workflow_run_outputs import project_node_outputs_for_workflow_run from core.workflow.workflow_run_outputs import project_node_outputs_for_workflow_run
from graphon.entities import WorkflowExecution, WorkflowNodeExecution from graphon.entities import WorkflowExecution, WorkflowNodeExecution
from graphon.enums import ( from graphon.enums import (
BuiltinNodeTypes,
WorkflowExecutionStatus, WorkflowExecutionStatus,
WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionMetadataKey,
WorkflowNodeExecutionStatus, WorkflowNodeExecutionStatus,
@@ -241,7 +243,10 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
) )
self._node_execution_cache[event.id] = domain_execution self._node_execution_cache[event.id] = domain_execution
self._workflow_node_execution_repository.save(domain_execution) if event.node_type == BuiltinNodeTypes.AGENT and event.node_version == "2":
self._workflow_node_execution_repository.save_synchronously(domain_execution)
else:
self._workflow_node_execution_repository.save(domain_execution)
snapshot = _NodeRuntimeSnapshot( snapshot = _NodeRuntimeSnapshot(
node_id=event.node_id, node_id=event.node_id,
@@ -361,7 +366,11 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
def _append_retry_history(self, execution: WorkflowNodeExecution, event: NodeRunRetryEvent) -> None: def _append_retry_history(self, execution: WorkflowNodeExecution, event: NodeRunRetryEvent) -> None:
"""Append a validated full attempt before repository truncation or offload.""" """Append a validated full attempt before repository truncation or offload."""
finished_at = naive_utc_now() finished_at = naive_utc_now()
process_data = dict(execution.process_data or {}) process_data = preserve_workflow_agent_binding_id(
event.node_run_result.process_data,
execution.process_data,
)
process_data = dict(process_data or {})
raw_history = process_data.get(RETRY_HISTORY_PROCESS_DATA_KEY) raw_history = process_data.get(RETRY_HISTORY_PROCESS_DATA_KEY)
history = list(raw_history) if isinstance(raw_history, list) else [] history = list(raw_history) if isinstance(raw_history, list) else []
projected_outputs = project_node_outputs_for_workflow_run( projected_outputs = project_node_outputs_for_workflow_run(
@@ -390,11 +399,12 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
next_process_data: Mapping[str, Any] | None, next_process_data: Mapping[str, Any] | None,
) -> Mapping[str, Any] | None: ) -> Mapping[str, Any] | None:
"""Keep internal retry history while replacing node-specific Process Data.""" """Keep internal retry history while replacing node-specific Process Data."""
merged_process_data = preserve_workflow_agent_binding_id(existing_process_data, next_process_data)
raw_history = (existing_process_data or {}).get(RETRY_HISTORY_PROCESS_DATA_KEY) raw_history = (existing_process_data or {}).get(RETRY_HISTORY_PROCESS_DATA_KEY)
if not isinstance(raw_history, list) or not raw_history: if not isinstance(raw_history, list) or not raw_history:
return next_process_data return merged_process_data
merged_process_data = dict(next_process_data or {}) merged_process_data = dict(merged_process_data or {})
merged_process_data[RETRY_HISTORY_PROCESS_DATA_KEY] = raw_history merged_process_data[RETRY_HISTORY_PROCESS_DATA_KEY] = raw_history
return merged_process_data return merged_process_data
@@ -440,6 +450,11 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
outputs=projected_outputs, outputs=projected_outputs,
metadata=node_result.metadata, metadata=node_result.metadata,
) )
else:
domain_execution.process_data = preserve_workflow_agent_binding_id(
node_result.process_data,
domain_execution.process_data,
)
self._workflow_node_execution_repository.save(domain_execution) self._workflow_node_execution_repository.save(domain_execution)
self._workflow_node_execution_repository.save_execution_data(domain_execution) self._workflow_node_execution_repository.save_execution_data(domain_execution)
@@ -16,6 +16,9 @@ from core.repositories.factory import (
OrderConfig, OrderConfig,
WorkflowNodeExecutionRepository, WorkflowNodeExecutionRepository,
) )
from core.repositories.sqlalchemy_workflow_node_execution_repository import (
SQLAlchemyWorkflowNodeExecutionRepository,
)
from graphon.entities import WorkflowNodeExecution from graphon.entities import WorkflowNodeExecution
from models import Account, CreatorUserRole, EndUser from models import Account, CreatorUserRole, EndUser
from models.workflow import WorkflowNodeExecutionTriggeredFrom from models.workflow import WorkflowNodeExecutionTriggeredFrom
@@ -49,6 +52,7 @@ class CeleryWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
_creator_user_role: CreatorUserRole _creator_user_role: CreatorUserRole
_execution_cache: dict[str, WorkflowNodeExecution] _execution_cache: dict[str, WorkflowNodeExecution]
_workflow_execution_mapping: dict[str, list[str]] _workflow_execution_mapping: dict[str, list[str]]
_sql_repository: SQLAlchemyWorkflowNodeExecutionRepository
def __init__( def __init__(
self, self,
@@ -98,6 +102,13 @@ class CeleryWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
# Cache for mapping workflow_execution_ids to execution IDs for efficient retrieval # Cache for mapping workflow_execution_ids to execution IDs for efficient retrieval
self._workflow_execution_mapping = {} self._workflow_execution_mapping = {}
self._sql_repository = SQLAlchemyWorkflowNodeExecutionRepository(
session_factory=session_factory,
tenant_id=tenant_id,
user=user,
app_id=app_id,
triggered_from=triggered_from,
)
logger.info( logger.info(
"Initialized CeleryWorkflowNodeExecutionRepository for tenant %s, app %s, triggered_from %s", "Initialized CeleryWorkflowNodeExecutionRepository for tenant %s, app %s, triggered_from %s",
@@ -149,6 +160,17 @@ class CeleryWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
# For now, we'll re-raise the exception # For now, we'll re-raise the exception
raise raise
@override
def save_synchronously(self, execution: WorkflowNodeExecution) -> None:
"""Create the Agent v2 caller row before runtime participant allocation."""
self._sql_repository.save_synchronously(execution)
self._execution_cache[execution.id] = execution
if execution.workflow_execution_id:
execution_ids = self._workflow_execution_mapping.setdefault(execution.workflow_execution_id, [])
if execution.id not in execution_ids:
execution_ids.append(execution.id)
@override @override
def get_by_workflow_execution( def get_by_workflow_execution(
self, self,
+2
View File
@@ -35,6 +35,8 @@ class WorkflowExecutionRepository(Protocol):
class WorkflowNodeExecutionRepository(Protocol): class WorkflowNodeExecutionRepository(Protocol):
def save(self, execution: WorkflowNodeExecution): ... def save(self, execution: WorkflowNodeExecution): ...
def save_synchronously(self, execution: WorkflowNodeExecution) -> None: ...
def save_execution_data(self, execution: WorkflowNodeExecution): ... def save_execution_data(self, execution: WorkflowNodeExecution): ...
def get_by_workflow_execution( def get_by_workflow_execution(
@@ -18,6 +18,7 @@ from tenacity import before_sleep_log, retry, retry_if_exception, stop_after_att
from configs import dify_config from configs import dify_config
from core.repositories.factory import OrderConfig, WorkflowNodeExecutionRepository from core.repositories.factory import OrderConfig, WorkflowNodeExecutionRepository
from core.workflow.node_execution_process_data import preserve_workflow_agent_binding_id
from extensions.ext_storage import storage from extensions.ext_storage import storage
from graphon.entities import WorkflowNodeExecution from graphon.entities import WorkflowNodeExecution
from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
@@ -372,6 +373,12 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
logger.exception("Failed to save workflow node execution after all retries") logger.exception("Failed to save workflow node execution after all retries")
raise raise
@override
def save_synchronously(self, execution: WorkflowNodeExecution) -> None:
"""Persist a caller row before an Agent v2 participant is materialized."""
self.save(execution)
def _persist_to_database(self, db_model: WorkflowNodeExecutionModel): def _persist_to_database(self, db_model: WorkflowNodeExecutionModel):
""" """
Persist the database model to the database. Persist the database model to the database.
@@ -386,6 +393,13 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
existing = session.get(WorkflowNodeExecutionModel, db_model.id) existing = session.get(WorkflowNodeExecutionModel, db_model.id)
if existing: if existing:
merged_process_data = preserve_workflow_agent_binding_id(
existing.process_data_dict,
db_model.process_data_dict,
)
db_model.process_data = (
_deterministic_json_dump(merged_process_data) if merged_process_data is not None else None
)
# Update existing record by copying all non-private attributes # Update existing record by copying all non-private attributes
for key, value in db_model.__dict__.items(): for key, value in db_model.__dict__.items():
if not key.startswith("_"): if not key.startswith("_"):
@@ -442,18 +456,25 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
else: else:
db_model.outputs = self._json_encode(domain_model.outputs) db_model.outputs = self._json_encode(domain_model.outputs)
if domain_model.process_data is not None: process_data = preserve_workflow_agent_binding_id(db_model.process_data_dict, domain_model.process_data)
if process_data is not None:
result = self._truncate_and_upload( result = self._truncate_and_upload(
domain_model.process_data, process_data,
domain_model.id, domain_model.id,
ExecutionOffLoadType.PROCESS_DATA, ExecutionOffLoadType.PROCESS_DATA,
) )
if result is not None: if result is not None:
db_model.process_data = self._json_encode(result.truncated_value) truncated_process_data = preserve_workflow_agent_binding_id(
domain_model.set_truncated_process_data(result.truncated_value) process_data,
result.truncated_value,
)
if truncated_process_data is None:
raise ValueError("truncated process data is unavailable")
db_model.process_data = self._json_encode(truncated_process_data)
domain_model.set_truncated_process_data(truncated_process_data)
offload_data = _replace_or_append_offload(offload_data, result.offload) offload_data = _replace_or_append_offload(offload_data, result.offload)
else: else:
db_model.process_data = self._json_encode(domain_model.process_data) db_model.process_data = self._json_encode(process_data)
db_model.offload_data = offload_data db_model.offload_data = offload_data
with self._session_factory() as session, session.begin(): with self._session_factory() as session, session.begin():
@@ -0,0 +1,27 @@
from collections.abc import Mapping
from typing import Any
WORKFLOW_AGENT_BINDING_ID_KEY = "workflow_agent_binding_id"
def preserve_workflow_agent_binding_id(
identity_source: Mapping[str, Any] | None,
process_data: Mapping[str, Any] | None,
) -> dict[str, Any] | None:
source_id = (identity_source or {}).get(WORKFLOW_AGENT_BINDING_ID_KEY)
target_id = (process_data or {}).get(WORKFLOW_AGENT_BINDING_ID_KEY)
for value in (source_id, target_id):
if value is not None and not isinstance(value, str):
raise ValueError("workflow_agent_binding_id must be a string")
if source_id is not None and target_id is not None and source_id != target_id:
raise ValueError("workflow_agent_binding_id does not match")
if process_data is None and source_id is None:
return None
merged = dict(process_data or {})
if source_id is not None:
merged[WORKFLOW_AGENT_BINDING_ID_KEY] = source_id
return merged
__all__ = ["WORKFLOW_AGENT_BINDING_ID_KEY", "preserve_workflow_agent_binding_id"]
+2 -2
View File
@@ -487,7 +487,7 @@ class DifyNodeFactory(NodeFactory):
from core.workflow.nodes.agent_v2.output_failure_orchestrator import OutputFailureOrchestrator from core.workflow.nodes.agent_v2.output_failure_orchestrator import OutputFailureOrchestrator
from core.workflow.nodes.agent_v2.output_file_rebacker import reback_tool_file_output from core.workflow.nodes.agent_v2.output_file_rebacker import reback_tool_file_output
from core.workflow.nodes.agent_v2.output_type_checker import PerOutputTypeChecker from core.workflow.nodes.agent_v2.output_type_checker import PerOutputTypeChecker
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentRuntimeSessionStore from core.workflow.nodes.agent_v2.session_store import WorkflowAgentWorkspaceStore
return { return {
"binding_resolver": WorkflowAgentBindingResolver(), "binding_resolver": WorkflowAgentBindingResolver(),
@@ -512,7 +512,7 @@ class DifyNodeFactory(NodeFactory):
# tenant validator resolves ToolFile (canonical) + UploadFile refs. # tenant validator resolves ToolFile (canonical) + UploadFile refs.
"type_checker": PerOutputTypeChecker(file_validator=AgentOutputFileTenantValidator()), "type_checker": PerOutputTypeChecker(file_validator=AgentOutputFileTenantValidator()),
"failure_orchestrator": OutputFailureOrchestrator(), "failure_orchestrator": OutputFailureOrchestrator(),
"session_store": WorkflowAgentRuntimeSessionStore(), "session_store": WorkflowAgentWorkspaceStore(),
} }
return { return {
"strategy_resolver": self._agent_strategy_resolver, "strategy_resolver": self._agent_strategy_resolver,
@@ -198,8 +198,23 @@ class AgentRuntimeSupport:
if model_schema: if model_schema:
model_schema = self._remove_unsupported_model_features_for_old_version(model_schema) model_schema = self._remove_unsupported_model_features_for_old_version(model_schema)
value["entity"] = model_schema.model_dump(mode="json") value["entity"] = model_schema.model_dump(mode="json")
# The model selector value from the workflow frontend only
# carries provider/model/mode — it does NOT include
# completion_params. AgentStrategy plugins (cot_agent,
# function_calling) read completion_params to build the
# LLMModelConfig that is backwards-invoked, and some model
# providers raise KeyError('required') when
# completion_params is empty because their parameter_rules
# declare required fields with no default. Populate
# completion_params with the defaults declared in the model
# schema so the plugin daemon always receives a valid set
# of model parameters.
if "completion_params" not in value:
value["completion_params"] = self._extract_default_completion_params(model_schema)
else: else:
value["entity"] = None value["entity"] = None
if "completion_params" not in value:
value["completion_params"] = {}
result[parameter_name] = value result[parameter_name] = value
return result return result
@@ -275,6 +290,24 @@ class AgentRuntimeSupport:
model_schema.features.remove(feature) model_schema.features.remove(feature)
return model_schema return model_schema
@staticmethod
def _extract_default_completion_params(model_schema: AIModelEntity) -> dict[str, Any]:
"""Build a completion_params dict from the model schema's parameter_rules.
The workflow Agent node's model-selector parameter only stores
provider/model/mode it never carries completion_params. When the
value is forwarded to the plugin daemon, AgentModelConfig defaults
completion_params to ``{}``, which causes some model providers to fail
because their parameter_rules declare required fields. This helper
collects the ``default`` value of every parameter_rule that has one so
the plugin daemon receives a valid, non-empty set of model parameters.
"""
completion_params: dict[str, Any] = {}
for rule in model_schema.parameter_rules:
if rule.default is not None:
completion_params[rule.name] = rule.default
return completion_params
@staticmethod @staticmethod
def _filter_mcp_type_tool( def _filter_mcp_type_tool(
strategy: ResolvedAgentStrategy, strategy: ResolvedAgentStrategy,
+149 -155
View File
@@ -18,13 +18,10 @@ from clients.agent_backend import (
AgentBackendRunEventAdapter, AgentBackendRunEventAdapter,
AgentBackendRunFailedInternalEvent, AgentBackendRunFailedInternalEvent,
AgentBackendRunSucceededInternalEvent, AgentBackendRunSucceededInternalEvent,
AgentBackendSessionCleanupPayload,
AgentBackendStreamError, AgentBackendStreamError,
AgentBackendStreamInternalEvent, AgentBackendStreamInternalEvent,
AgentBackendTransportError, AgentBackendTransportError,
AgentBackendValidationError, AgentBackendValidationError,
RuntimeLayerSpec,
extract_runtime_layer_specs,
) )
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
@@ -33,11 +30,12 @@ from core.workflow.nodes.human_input.session_binding import default_session_bind
from core.workflow.system_variables import SystemVariableKey, get_system_text from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.entities.pause_reason import HitlRequired, SchedulingPause from graphon.entities.pause_reason import HitlRequired, SchedulingPause
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent from graphon.graph_events import NodeRunPauseRequestedEvent
from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent
from graphon.nodes.base.node import Node from graphon.nodes.base.node import Node
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from services.agent.prompt_mentions import extract_workflow_node_output_selectors 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 services.agent.workspace_service import AgentWorkspaceNotFoundError
from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason
from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
@@ -56,7 +54,7 @@ from .runtime_request_builder import (
WorkflowAgentRuntimeRequestBuilder, WorkflowAgentRuntimeRequestBuilder,
WorkflowAgentRuntimeRequestBuildError, WorkflowAgentRuntimeRequestBuildError,
) )
from .session_store import WorkflowAgentRuntimeSessionStore, WorkflowAgentSessionScope from .session_store import WorkflowAgentSessionScope, WorkflowAgentWorkspaceStore
if TYPE_CHECKING: if TYPE_CHECKING:
from graphon.entities import GraphInitParams from graphon.entities import GraphInitParams
@@ -68,7 +66,7 @@ logger = logging.getLogger(__name__)
# Stage 4 §5+§7: the terminal events that `_consume_event_stream` may return. # Stage 4 §5+§7: the terminal events that `_consume_event_stream` may return.
# Stream + started events are filtered out before we yield; transport errors # Stream + started events are filtered out before we yield; transport errors
# are surfaced as a separate StreamCompletedEvent in the second tuple slot. # are surfaced as a separate StreamCompletedEvent in the second tuple slot.
_TerminalAgentBackendEvent = ( type _TerminalAgentBackendEvent = (
AgentBackendRunSucceededInternalEvent AgentBackendRunSucceededInternalEvent
| AgentBackendRunFailedInternalEvent | AgentBackendRunFailedInternalEvent
| AgentBackendRunCancelledInternalEvent | AgentBackendRunCancelledInternalEvent
@@ -93,7 +91,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
output_adapter: WorkflowAgentOutputAdapter, output_adapter: WorkflowAgentOutputAdapter,
type_checker: PerOutputTypeChecker, type_checker: PerOutputTypeChecker,
failure_orchestrator: OutputFailureOrchestrator, failure_orchestrator: OutputFailureOrchestrator,
session_store: WorkflowAgentRuntimeSessionStore | None = None, session_store: WorkflowAgentWorkspaceStore,
) -> None: ) -> None:
super().__init__( super().__init__(
node_id=node_id, node_id=node_id,
@@ -130,7 +128,34 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
return reason return reason
@override @override
def _run(self) -> Generator[NodeEventBase, None, None]: def _run(self) -> Generator[NodeEventBase | NodeRunPauseRequestedEvent, None, None]:
inputs: dict[str, Any] = {}
process_data: dict[str, Any] = {}
metadata: dict[str, Any] = {
"agent_backend": {
"status": "not_started",
}
}
try:
yield from self._run_inner(inputs=inputs, process_data=process_data, metadata=metadata)
except Exception as error:
if not process_data:
raise
yield self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
error=str(error),
error_type="agent_workflow_node_runtime_error",
)
def _run_inner(
self,
*,
inputs: dict[str, Any],
process_data: dict[str, Any],
metadata: dict[str, Any],
) -> Generator[NodeEventBase | NodeRunPauseRequestedEvent, None, None]:
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY)) dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
workflow_id = self.graph_init_params.workflow_id workflow_id = self.graph_init_params.workflow_id
workflow_run_id = get_system_text( workflow_run_id = get_system_text(
@@ -143,21 +168,24 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
self.graph_runtime_state.variable_pool, self.graph_runtime_state.variable_pool,
SystemVariableKey.CONVERSATION_ID, SystemVariableKey.CONVERSATION_ID,
) )
inputs: dict[str, Any] = {}
process_data: dict[str, Any] = {}
metadata: dict[str, Any] = {
"agent_backend": {
"status": "not_started",
}
}
# ──── Setup: resolve binding once + extract declared outputs for stage 4 checks ──── # ──── Setup: resolve binding once + extract declared outputs for stage 4 checks ────
try: try:
existing_scope = self._session_store.load_existing_node_execution_scope(
tenant_id=dify_ctx.tenant_id,
app_id=dify_ctx.app_id,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
node_id=self._node_id,
node_execution_id=self.execution_id,
)
bundle = self._binding_resolver.resolve( bundle = self._binding_resolver.resolve(
tenant_id=dify_ctx.tenant_id, tenant_id=dify_ctx.tenant_id,
app_id=dify_ctx.app_id, app_id=dify_ctx.app_id,
workflow_id=workflow_id, workflow_id=workflow_id,
node_id=self._node_id, node_id=self._node_id,
binding_id=existing_scope.workflow_agent_binding_id if existing_scope is not None else None,
snapshot_id=existing_scope.agent_config_snapshot_id if existing_scope is not None else None,
) )
except WorkflowAgentBindingError as error: except WorkflowAgentBindingError as error:
yield self._failure_event( yield self._failure_event(
@@ -168,20 +196,31 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
error_type=error.error_code, error_type=error.error_code,
) )
return return
except AgentWorkspaceNotFoundError as error:
yield self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
error=str(error),
error_type="agent_workflow_node_runtime_error",
)
return
process_data = { process_data.update(
"agent_id": bundle.agent.id, {
"agent_config_snapshot_id": bundle.snapshot.id, "agent_id": bundle.agent.id,
"binding_id": bundle.binding.id, "agent_config_snapshot_id": bundle.snapshot.id,
} "workflow_agent_binding_id": bundle.binding.id,
session_scope = WorkflowAgentSessionScope( }
)
session_scope = existing_scope or WorkflowAgentSessionScope(
tenant_id=dify_ctx.tenant_id, tenant_id=dify_ctx.tenant_id,
app_id=dify_ctx.app_id, app_id=dify_ctx.app_id,
workflow_id=workflow_id, workflow_id=workflow_id,
workflow_run_id=workflow_run_id, workflow_run_id=workflow_run_id,
node_id=self._node_id, node_id=self._node_id,
node_execution_id=self.id, node_execution_id=self.execution_id,
binding_id=bundle.binding.id, workflow_agent_binding_id=bundle.binding.id,
agent_id=bundle.agent.id, agent_id=bundle.agent.id,
agent_config_snapshot_id=bundle.snapshot.id, agent_config_snapshot_id=bundle.snapshot.id,
) )
@@ -200,47 +239,53 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
# the second Agent run as deferred_tool_results; if it is somehow still # the second Agent run as deferred_tool_results; if it is somehow still
# waiting, re-emit the same pause defensively. # waiting, re-emit the same pause defensively.
deferred_tool_results = None deferred_tool_results = None
if self._session_store is not None: stored_session = self._session_store.load_or_create_node_execution_session(
stored_session = self._session_store.load_active_session(session_scope) session_scope,
if stored_session is not None and stored_session.pending_form_id is not None: home_snapshot_id=bundle.snapshot.home_snapshot_id,
resume_outcome = resolve_ask_human_form( )
form_id=stored_session.pending_form_id, if stored_session.pending_form_id is not None:
tenant_id=dify_ctx.tenant_id, resume_outcome = resolve_ask_human_form(
node_id=self._node_id, form_id=stored_session.pending_form_id,
tenant_id=dify_ctx.tenant_id,
node_id=self._node_id,
)
if resume_outcome is not None and resume_outcome.repause is not None:
yield self._pause_event(
reason=resume_outcome.repause,
inputs=inputs,
process_data=process_data,
metadata=metadata,
)
return
if (
resume_outcome is not None
and resume_outcome.deferred_result is not None
and stored_session.pending_tool_call_id is not None
):
deferred_tool_results = build_deferred_tool_results(
tool_call_id=stored_session.pending_tool_call_id,
result=resume_outcome.deferred_result,
) )
if resume_outcome is not None and resume_outcome.repause is not None:
yield PauseRequestedEvent(reason=self._to_graph_pause_reason(resume_outcome.repause))
return
if (
resume_outcome is not None
and resume_outcome.deferred_result is not None
and stored_session.pending_tool_call_id is not None
):
deferred_tool_results = build_deferred_tool_results(
tool_call_id=stored_session.pending_tool_call_id,
result=resume_outcome.deferred_result,
)
# ──── Retry loop (Stage 4 §7) ──── # ──── Retry loop (Stage 4 §7) ────
attempt = 0 attempt = 0
while True: while True:
try: try:
session_snapshot = None
if self._session_store is not None:
session_snapshot = self._session_store.load_active_snapshot(session_scope)
runtime_request = self._runtime_request_builder.build( runtime_request = self._runtime_request_builder.build(
WorkflowAgentRuntimeBuildContext( WorkflowAgentRuntimeBuildContext(
dify_context=dify_ctx, dify_context=dify_ctx,
workflow_id=workflow_id, workflow_id=workflow_id,
workflow_run_id=workflow_run_id, workflow_run_id=workflow_run_id,
node_id=self._node_id, node_id=self._node_id,
node_execution_id=self.id, node_execution_id=self.execution_id,
variable_pool=self.graph_runtime_state.variable_pool, variable_pool=self.graph_runtime_state.variable_pool,
binding=bundle.binding, binding=bundle.binding,
agent=bundle.agent, agent=bundle.agent,
snapshot=bundle.snapshot, snapshot=bundle.snapshot,
binding_id=stored_session.binding_id,
backend_binding_ref=stored_session.backend_binding_ref,
attempt=attempt, attempt=attempt,
session_snapshot=session_snapshot, session_snapshot=stored_session.session_snapshot,
deferred_tool_results=deferred_tool_results, deferred_tool_results=deferred_tool_results,
) )
) )
@@ -266,8 +311,9 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
# Capture inputs only from the first attempt so retry doesn't churn the # Capture inputs only from the first attempt so retry doesn't churn the
# node's "inputs" payload that ends up in the workflow detail view. # node's "inputs" payload that ends up in the workflow detail view.
if attempt == 0: if attempt == 0:
inputs = {"agent_backend_request": runtime_request.redacted_request} inputs["agent_backend_request"] = runtime_request.redacted_request
metadata = dict(runtime_request.metadata) metadata.clear()
metadata.update(runtime_request.metadata)
metadata["attempt"] = attempt metadata["attempt"] = attempt
try: try:
@@ -288,7 +334,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
"status": create_response.status, "status": create_response.status,
} }
terminal_event, exhausted = self._consume_event_stream(create_response.run_id, metadata) terminal_event, exhausted = self._consume_event_stream(
create_response.run_id,
inputs=inputs,
process_data=process_data,
metadata=metadata,
)
if exhausted is not None: if exhausted is not None:
# Streaming error / unexpected end — surface immediately without # Streaming error / unexpected end — surface immediately without
# retrying because the failure is transport-level. # retrying because the failure is transport-level.
@@ -349,29 +400,23 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
) )
self._save_session_snapshot( self._save_session_snapshot(
session_scope=session_scope, session_scope=session_scope,
backend_run_id=terminal_event.run_id, binding_id=stored_session.binding_id,
snapshot=terminal_event.session_snapshot, snapshot=terminal_event.session_snapshot,
runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition),
metadata=metadata, metadata=metadata,
pending_form_id=pending_form_id, pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id, pending_tool_call_id=pending_tool_call_id,
) )
yield PauseRequestedEvent(reason=self._to_graph_pause_reason(pause_reason)) yield self._pause_event(
return reason=pause_reason,
inputs=inputs,
# Non-success terminal (failed / cancelled) skips per-output process_data=process_data,
# post-processing — the backend itself already failed. We also retire
# the local ACTIVE session row so a workflow loop back into the same
# Agent node cannot resume from a stale snapshot. The failed agent
# backend layers (suspended per ``on_exit``) are left for agent
# backend's own GC; this row will no longer be picked up by the
# workflow-terminal cleanup layer.
if not isinstance(terminal_event, AgentBackendRunSucceededInternalEvent):
self._mark_session_cleaned_on_failure(
session_scope=session_scope,
backend_run_id=terminal_event.run_id,
metadata=metadata, metadata=metadata,
) )
return
# A failed attempt does not retire the product-owned Binding. The
# Workflow Run terminal lifecycle event owns that transition.
if not isinstance(terminal_event, AgentBackendRunSucceededInternalEvent):
yield StreamCompletedEvent( yield StreamCompletedEvent(
node_run_result=self._output_adapter.build_failure_result( node_run_result=self._output_adapter.build_failure_result(
event=terminal_event, event=terminal_event,
@@ -384,9 +429,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
self._save_session_snapshot( self._save_session_snapshot(
session_scope=session_scope, session_scope=session_scope,
backend_run_id=terminal_event.run_id, binding_id=stored_session.binding_id,
snapshot=terminal_event.session_snapshot, snapshot=terminal_event.session_snapshot,
runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition),
metadata=metadata, metadata=metadata,
) )
@@ -458,6 +502,9 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
def _consume_event_stream( def _consume_event_stream(
self, self,
run_id: str, run_id: str,
*,
inputs: dict[str, Any],
process_data: dict[str, Any],
metadata: dict[str, Any], metadata: dict[str, Any],
) -> tuple[ ) -> tuple[
_TerminalAgentBackendEvent | None, _TerminalAgentBackendEvent | None,
@@ -507,8 +554,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
return internal_event, None return internal_event, None
self._cancel_backend_run(run_id, reason="unexpected_event") self._cancel_backend_run(run_id, reason="unexpected_event")
return None, self._failure_event( return None, self._failure_event(
inputs={}, inputs=inputs,
process_data={}, process_data=process_data,
metadata=metadata, metadata=metadata,
error=f"Unexpected internal event type {internal_event.type!r}", error=f"Unexpected internal event type {internal_event.type!r}",
error_type="agent_backend_stream_error", error_type="agent_backend_stream_error",
@@ -516,8 +563,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
except AgentBackendError as error: except AgentBackendError as error:
self._cancel_backend_run(run_id, reason=self._stream_stop_reason()) self._cancel_backend_run(run_id, reason=self._stream_stop_reason())
return None, self._failure_event( return None, self._failure_event(
inputs={}, inputs=inputs,
process_data={}, process_data=process_data,
metadata=metadata, metadata=metadata,
error=str(error), error=str(error),
error_type=self._agent_backend_error_type(error), error_type=self._agent_backend_error_type(error),
@@ -525,8 +572,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
except Exception as error: except Exception as error:
self._cancel_backend_run(run_id, reason=self._stream_stop_reason()) self._cancel_backend_run(run_id, reason=self._stream_stop_reason())
return None, self._failure_event( return None, self._failure_event(
inputs={}, inputs=inputs,
process_data={}, process_data=process_data,
metadata=metadata, metadata=metadata,
error=str(error), error=str(error),
error_type="agent_backend_stream_error", error_type="agent_backend_stream_error",
@@ -596,21 +643,17 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
self, self,
*, *,
session_scope: WorkflowAgentSessionScope, session_scope: WorkflowAgentSessionScope,
backend_run_id: str, binding_id: str,
snapshot: CompositorSessionSnapshot | None, snapshot: CompositorSessionSnapshot | None,
runtime_layer_specs: list[RuntimeLayerSpec],
metadata: dict[str, Any], metadata: dict[str, Any],
pending_form_id: str | None = None, pending_form_id: str | None = None,
pending_tool_call_id: str | None = None, pending_tool_call_id: str | None = None,
) -> None: ) -> None:
if self._session_store is None:
return
try: try:
self._session_store.save_active_snapshot( self._session_store.save_active_snapshot(
scope=session_scope, scope=session_scope,
backend_run_id=backend_run_id, binding_id=binding_id,
snapshot=snapshot, snapshot=snapshot,
runtime_layer_specs=runtime_layer_specs,
pending_form_id=pending_form_id, pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id, pending_tool_call_id=pending_tool_call_id,
) )
@@ -619,88 +662,18 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
metadata["agent_backend"] = agent_backend metadata["agent_backend"] = agent_backend
except Exception: except Exception:
logger.warning( logger.warning(
"Failed to persist workflow Agent runtime session snapshot: " "Failed to persist workflow Agent Binding session snapshot: "
"tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s backend_run_id=%s", "tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s",
session_scope.tenant_id, session_scope.tenant_id,
session_scope.workflow_run_id, session_scope.workflow_run_id,
session_scope.node_id, session_scope.node_id,
session_scope.binding_id, session_scope.workflow_agent_binding_id,
session_scope.agent_id, session_scope.agent_id,
backend_run_id,
exc_info=True, exc_info=True,
) )
agent_backend = dict(metadata.get("agent_backend") or {}) agent_backend = dict(metadata.get("agent_backend") or {})
agent_backend["session_snapshot_persisted"] = False agent_backend["session_snapshot_persisted"] = False
agent_backend["session_snapshot_persist_error"] = "workflow_agent_runtime_session_store_error" agent_backend["session_snapshot_persist_error"] = "workflow_agent_workspace_store_error"
metadata["agent_backend"] = agent_backend
def _mark_session_cleaned_on_failure(
self,
*,
session_scope: WorkflowAgentSessionScope,
backend_run_id: str,
metadata: dict[str, Any],
) -> 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 {})
agent_backend["session_snapshot_cleaned_on_failure"] = True
metadata["agent_backend"] = agent_backend
except Exception:
logger.warning(
"Failed to mark workflow Agent runtime session cleaned 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,
)
agent_backend = dict(metadata.get("agent_backend") or {})
agent_backend["session_snapshot_cleaned_on_failure"] = False
agent_backend["session_snapshot_cleanup_error"] = "workflow_agent_runtime_session_store_error"
metadata["agent_backend"] = agent_backend metadata["agent_backend"] = agent_backend
@staticmethod @staticmethod
@@ -742,6 +715,27 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
) )
) )
def _pause_event(
self,
*,
reason: HumanInputRequired | SchedulingPause,
inputs: dict[str, Any],
process_data: dict[str, Any],
metadata: dict[str, Any],
) -> NodeRunPauseRequestedEvent:
return NodeRunPauseRequestedEvent(
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.PAUSED,
inputs=inputs,
process_data=process_data,
metadata={WorkflowNodeExecutionMetadataKey.AGENT_LOG: metadata},
),
reason=self._to_graph_pause_reason(reason),
)
@staticmethod @staticmethod
def _agent_backend_error_type(error: AgentBackendError) -> str: def _agent_backend_error_type(error: AgentBackendError) -> str:
if isinstance(error, AgentBackendValidationError): if isinstance(error, AgentBackendValidationError):
@@ -41,18 +41,27 @@ class WorkflowAgentBindingResolver:
app_id: str, app_id: str,
workflow_id: str, workflow_id: str,
node_id: str, node_id: str,
binding_id: str | None = None,
snapshot_id: str | None = None,
) -> WorkflowAgentBindingBundle: ) -> WorkflowAgentBindingBundle:
with session_factory.create_session() as session: """Resolve the current binding, optionally at a generation pinned by an existing execution."""
binding = session.scalar(
select(WorkflowAgentNodeBinding) if (binding_id is None) != (snapshot_id is None):
.where( raise WorkflowAgentBindingError(
WorkflowAgentNodeBinding.tenant_id == tenant_id, "agent_binding_generation_invalid",
WorkflowAgentNodeBinding.app_id == app_id, "Workflow Agent binding and config snapshot must be pinned together.",
WorkflowAgentNodeBinding.workflow_id == workflow_id,
WorkflowAgentNodeBinding.node_id == node_id,
)
.limit(1)
) )
with session_factory.create_session() as session:
binding_stmt = select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == tenant_id,
WorkflowAgentNodeBinding.app_id == app_id,
WorkflowAgentNodeBinding.workflow_id == workflow_id,
WorkflowAgentNodeBinding.node_id == node_id,
)
if binding_id is not None:
binding_stmt = binding_stmt.where(WorkflowAgentNodeBinding.id == binding_id)
binding = session.scalar(binding_stmt.limit(1))
if binding is None: if binding is None:
raise WorkflowAgentBindingError( raise WorkflowAgentBindingError(
"agent_binding_not_found", "agent_binding_not_found",
@@ -77,12 +86,16 @@ class WorkflowAgentBindingResolver:
f"Agent {binding.agent_id} is not available or has not been published.", f"Agent {binding.agent_id} is not available or has not been published.",
) )
snapshot_id = ( effective_snapshot_id = (
agent.active_config_snapshot_id (
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT agent.active_config_snapshot_id
else binding.current_snapshot_id if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else binding.current_snapshot_id
)
if snapshot_id is None
else snapshot_id
) )
if snapshot_id is None: if effective_snapshot_id is None:
raise WorkflowAgentBindingError( raise WorkflowAgentBindingError(
"agent_config_snapshot_not_found", "agent_config_snapshot_not_found",
"Workflow Agent binding has no current config snapshot.", "Workflow Agent binding has no current config snapshot.",
@@ -93,14 +106,14 @@ class WorkflowAgentBindingResolver:
.where( .where(
AgentConfigSnapshot.tenant_id == tenant_id, AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent.id, AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == snapshot_id, AgentConfigSnapshot.id == effective_snapshot_id,
) )
.limit(1) .limit(1)
) )
if snapshot is None: if snapshot is None:
raise WorkflowAgentBindingError( raise WorkflowAgentBindingError(
"agent_config_snapshot_not_found", "agent_config_snapshot_not_found",
f"Agent config snapshot {snapshot_id} not found.", f"Agent config snapshot {effective_snapshot_id} not found.",
) )
session.expunge(binding) session.expunge(binding)
@@ -33,7 +33,6 @@ from dify_agent.layers.shell import (
DifyShellCliToolConfig, DifyShellCliToolConfig,
DifyShellEnvVarConfig, DifyShellEnvVarConfig,
DifyShellLayerConfig, DifyShellLayerConfig,
DifyShellSandboxConfig,
DifyShellSecretRefConfig, DifyShellSecretRefConfig,
) )
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
@@ -136,6 +135,8 @@ class WorkflowAgentRuntimeBuildContext:
binding: WorkflowAgentNodeBinding binding: WorkflowAgentNodeBinding
agent: Agent agent: Agent
snapshot: AgentConfigSnapshot snapshot: AgentConfigSnapshot
binding_id: str
backend_binding_ref: str
# Stage 4 §7 / D-4: 0 for the first run, then incremented per retry. Drives the # Stage 4 §7 / D-4: 0 for the first run, then incremented per retry. Drives the
# idempotency key so the backend treats each retry as a fresh request. # idempotency key so the backend treats each retry as a fresh request.
attempt: int = 0 attempt: int = 0
@@ -251,6 +252,7 @@ class WorkflowAgentRuntimeRequestBuilder:
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from), agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value), invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
), ),
backend_binding_ref=context.backend_binding_ref,
agent_soul_prompt=soul_prompt or None, agent_soul_prompt=soul_prompt or None,
workflow_node_job_prompt=workflow_job_prompt, workflow_node_job_prompt=workflow_job_prompt,
user_prompt=user_prompt, user_prompt=user_prompt,
@@ -739,7 +741,6 @@ class WorkflowAgentRuntimeRequestBuilder:
def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfig: def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfig:
"""Map Agent Soul shell-adjacent fields into the Agent backend shell config.""" """Map Agent Soul shell-adjacent fields into the Agent backend shell config."""
sandbox_config = _plain_mapping(agent_soul.sandbox.config)
return DifyShellLayerConfig( return DifyShellLayerConfig(
cli_tools=[ cli_tools=[
tool tool
@@ -750,12 +751,6 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
secret_refs=[ secret_refs=[
secret for secret in (_shell_secret_ref(item) for item in agent_soul.env.secret_refs) if secret is not None secret for secret in (_shell_secret_ref(item) for item in agent_soul.env.secret_refs) if secret is not None
], ],
sandbox=DifyShellSandboxConfig(
provider=agent_soul.sandbox.provider,
config=sandbox_config,
)
if agent_soul.sandbox.provider or sandbox_config
else None,
) )
@@ -1,126 +0,0 @@
"""Workflow terminal layer that retires Agent backend sessions asynchronously."""
from __future__ import annotations
import logging
from typing import override
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 (
GraphEngineEvent,
GraphRunAbortedEvent,
GraphRunFailedEvent,
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__)
class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
"""Retire workflow-owned Agent runtime sessions when the workflow ends.
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.
"""
_TERMINAL_EVENTS = (
GraphRunSucceededEvent,
GraphRunPartialSucceededEvent,
GraphRunFailedEvent,
GraphRunAbortedEvent,
)
def __init__(self, *, session_store: WorkflowAgentRuntimeSessionStore) -> None:
super().__init__()
self._session_store = session_store
@override
def on_graph_start(self) -> None:
return
@override
def on_event(self, event: GraphEngineEvent) -> None:
if not isinstance(event, self._TERMINAL_EVENTS):
return
workflow_run_id = get_system_text(
self.graph_runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
)
if not workflow_run_id:
logger.warning("Skipping workflow Agent session cleanup: workflow_run_id is missing.")
return
for stored_session in self._session_store.list_active_sessions(workflow_run_id=workflow_run_id):
self._cleanup_session(stored_session)
@override
def on_graph_end(self, error: Exception | None) -> None:
return
def _cleanup_session(self, stored_session: StoredWorkflowAgentSession) -> None:
scope = stored_session.scope
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.agent_id,
stored_session.backend_run_id,
exc_info=True,
)
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 workflow-owned session store."""
return WorkflowAgentSessionCleanupLayer(session_store=WorkflowAgentRuntimeSessionStore())
+227 -146
View File
@@ -1,31 +1,32 @@
"""Workflow Agent participant persistence keyed by node execution."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field import json
import time
from dataclasses import dataclass
from agenton.compositor import CompositorSessionSnapshot from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import RuntimeLayerSpec
from pydantic import TypeAdapter
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from models.agent import ( from models.agent import (
AgentRuntimeSessionOwnerType, AgentConfigVersionKind,
WorkflowAgentRuntimeSession, AgentWorkingResourceStatus,
WorkflowAgentRuntimeSessionStatus, AgentWorkspace,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
)
from models.workflow import WorkflowNodeExecutionModel
from services.agent.workspace_service import (
AgentWorkspaceNotFoundError,
AgentWorkspaceService,
WorkspaceOwnerScope,
) )
_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec]) _CALLER_VISIBILITY_ATTEMPTS = 60
_CALLER_VISIBILITY_INTERVAL_SECONDS = 0.05
def _serialize_specs(specs: list[RuntimeLayerSpec]) -> str:
return _SPECS_ADAPTER.dump_json(specs).decode()
def _deserialize_specs(value: str | None) -> list[RuntimeLayerSpec]:
if not value:
return []
return _SPECS_ADAPTER.validate_json(value)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -36,171 +37,251 @@ class WorkflowAgentSessionScope:
workflow_run_id: str | None workflow_run_id: str | None
node_id: str node_id: str
node_execution_id: str node_execution_id: str
binding_id: str workflow_agent_binding_id: str
agent_id: str agent_id: str
agent_config_snapshot_id: str agent_config_snapshot_id: str
@property
def workspace_owner(self) -> WorkspaceOwnerScope:
return WorkspaceOwnerScope(
tenant_id=self.tenant_id,
app_id=self.app_id,
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
owner_id=self.workflow_run_id or self.node_execution_id,
owner_scope_key=f"{self.node_id}:{self.workflow_agent_binding_id}",
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class StoredWorkflowAgentSession: class StoredWorkflowAgentSession:
scope: WorkflowAgentSessionScope scope: WorkflowAgentSessionScope
session_snapshot: CompositorSessionSnapshot binding_id: str
backend_run_id: str | None workspace_id: str
runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list) backend_binding_ref: str
# ENG-637: set while the session is paused on a dify.ask_human deferred call. session_snapshot: CompositorSessionSnapshot | None
pending_form_id: str | None = None pending_form_id: str | None = None
pending_tool_call_id: str | None = None pending_tool_call_id: str | None = None
class WorkflowAgentRuntimeSessionStore: class WorkflowAgentWorkspaceStore:
"""Stores Agent backend session snapshots for workflow Agent node re-entry.""" """Load or create the participant named by a node execution caller row."""
def load_active_snapshot(self, scope: WorkflowAgentSessionScope) -> CompositorSessionSnapshot | None: def load_existing_node_execution_scope(
stored = self.load_active_session(scope) self,
return stored.session_snapshot if stored is not None else None *,
tenant_id: str,
def load_active_session(self, scope: WorkflowAgentSessionScope) -> StoredWorkflowAgentSession | None: app_id: str,
"""Load the active session row including any pending ask_human correlation.""" workflow_id: str,
if scope.workflow_run_id is None: workflow_run_id: str | None,
return None node_id: str,
node_execution_id: str,
) -> WorkflowAgentSessionScope | None:
"""Return the generation pinned by an existing node execution participant."""
with session_factory.create_session() as session: with session_factory.create_session() as session:
row = session.scalar( execution = self._load_execution_by_identity(
select(WorkflowAgentRuntimeSession).where( session=session,
WorkflowAgentRuntimeSession.tenant_id == scope.tenant_id, tenant_id=tenant_id,
WorkflowAgentRuntimeSession.workflow_run_id == scope.workflow_run_id, app_id=app_id,
WorkflowAgentRuntimeSession.node_id == scope.node_id, workflow_id=workflow_id,
WorkflowAgentRuntimeSession.binding_id == scope.binding_id, workflow_run_id=workflow_run_id,
WorkflowAgentRuntimeSession.agent_id == scope.agent_id, node_id=node_id,
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE, node_execution_id=node_execution_id,
)
) )
if row is None: binding_id = execution.agent_workspace_binding_id
if binding_id is None:
return None return None
return StoredWorkflowAgentSession( process_data = execution.process_data_dict
scope=scope, if not isinstance(process_data, dict):
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is invalid")
backend_run_id=row.backend_run_id, workflow_agent_binding_id = process_data.get("workflow_agent_binding_id")
runtime_layer_specs=_deserialize_specs(row.composition_layer_specs), if not isinstance(workflow_agent_binding_id, str):
pending_form_id=row.pending_form_id, raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is missing")
pending_tool_call_id=row.pending_tool_call_id, owner_scope = WorkspaceOwnerScope(
tenant_id=tenant_id,
app_id=app_id,
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
owner_id=workflow_run_id or node_execution_id,
owner_scope_key=f"{node_id}:{workflow_agent_binding_id}",
)
binding = AgentWorkspaceService.get_active_binding(
session=session,
tenant_id=tenant_id,
binding_id=binding_id,
expected_owner_scope=owner_scope,
)
if binding is None or binding.agent_config_version_kind != AgentConfigVersionKind.SNAPSHOT:
raise AgentWorkspaceNotFoundError("Workflow node participant Binding is unavailable")
return WorkflowAgentSessionScope(
tenant_id=tenant_id,
app_id=app_id,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
workflow_agent_binding_id=workflow_agent_binding_id,
agent_id=binding.agent_id,
agent_config_snapshot_id=binding.agent_config_version_id,
) )
def list_active_sessions(self, *, workflow_run_id: str) -> list[StoredWorkflowAgentSession]: def load_or_create_node_execution_session(
self, scope: WorkflowAgentSessionScope, *, home_snapshot_id: str
) -> StoredWorkflowAgentSession:
with session_factory.create_session() as session: with session_factory.create_session() as session:
rows = session.scalars( execution = self._load_execution(session=session, scope=scope)
select(WorkflowAgentRuntimeSession).where( process_data = execution.process_data_dict
WorkflowAgentRuntimeSession.workflow_run_id == workflow_run_id, if process_data is None:
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE, process_data = {}
if not isinstance(process_data, dict):
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is invalid")
stored_workflow_binding_id = process_data.get("workflow_agent_binding_id")
if stored_workflow_binding_id is not None and stored_workflow_binding_id != scope.workflow_agent_binding_id:
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity does not match")
binding_id = execution.agent_workspace_binding_id
if binding_id is None:
binding = AgentWorkspaceService.create_binding(
session=session,
scope=scope.workspace_owner,
agent_id=scope.agent_id,
base_home_snapshot_id=home_snapshot_id,
agent_config_version_id=scope.agent_config_snapshot_id,
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
) )
).all() execution.agent_workspace_binding_id = binding.id
return [ execution.process_data = json.dumps(
StoredWorkflowAgentSession( {
scope=WorkflowAgentSessionScope( **process_data,
tenant_id=row.tenant_id, "workflow_agent_binding_id": scope.workflow_agent_binding_id,
app_id=row.app_id, },
# These columns are nullable on the unified runtime-session ensure_ascii=False,
# table (workflow_run ⊕ conversation owner), but are always
# populated for a workflow-owned row; coerce for the typed scope.
workflow_id=row.workflow_id or "",
workflow_run_id=row.workflow_run_id,
node_id=row.node_id or "",
node_execution_id=row.node_execution_id or "",
binding_id=row.binding_id or "",
agent_id=row.agent_id,
agent_config_snapshot_id=row.agent_config_snapshot_id or "",
),
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
backend_run_id=row.backend_run_id,
runtime_layer_specs=_deserialize_specs(row.composition_layer_specs),
) )
for row in rows session.commit()
] else:
if stored_workflow_binding_id is None:
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is missing")
resolved_binding = AgentWorkspaceService.get_active_binding(
session=session,
tenant_id=scope.tenant_id,
binding_id=binding_id,
expected_owner_scope=scope.workspace_owner,
)
if resolved_binding is None or resolved_binding.agent_id != scope.agent_id:
raise AgentWorkspaceNotFoundError("Workflow node participant Binding is unavailable")
binding = resolved_binding
AgentWorkspaceService.validate_binding_generation(
binding,
base_home_snapshot_id=home_snapshot_id,
agent_config_version_id=scope.agent_config_snapshot_id,
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
)
return self._stored(scope, binding)
def save_active_snapshot( def save_active_snapshot(
self, self,
*, *,
scope: WorkflowAgentSessionScope, scope: WorkflowAgentSessionScope,
backend_run_id: str, binding_id: str,
snapshot: CompositorSessionSnapshot | None, snapshot: CompositorSessionSnapshot | None,
runtime_layer_specs: list[RuntimeLayerSpec],
pending_form_id: str | None = None, pending_form_id: str | None = None,
pending_tool_call_id: str | None = None, pending_tool_call_id: str | None = None,
) -> None: ) -> None:
if scope.workflow_run_id is None or snapshot is None: if snapshot is None:
return return
AgentWorkspaceService.save_binding_session_snapshot(
tenant_id=scope.tenant_id,
binding_id=binding_id,
session_snapshot=snapshot.model_dump_json(),
pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id,
)
snapshot_json = snapshot.model_dump_json() def retire_workflow_run(self, *, tenant_id: str, app_id: str, workflow_run_id: str) -> list[str]:
specs_json = _serialize_specs(runtime_layer_specs) """Retire active Workspaces, commit, and return active or already-retired IDs for collection."""
retired: list[str] = []
with session_factory.create_session() as session: with session_factory.create_session() as session:
row = session.scalar( workspaces = session.scalars(
select(WorkflowAgentRuntimeSession).where( select(AgentWorkspace).where(
WorkflowAgentRuntimeSession.tenant_id == scope.tenant_id, AgentWorkspace.tenant_id == tenant_id,
WorkflowAgentRuntimeSession.workflow_run_id == scope.workflow_run_id, AgentWorkspace.app_id == app_id,
WorkflowAgentRuntimeSession.node_id == scope.node_id, AgentWorkspace.owner_type == AgentWorkspaceOwnerType.WORKFLOW_RUN,
WorkflowAgentRuntimeSession.binding_id == scope.binding_id, AgentWorkspace.owner_id == workflow_run_id,
WorkflowAgentRuntimeSession.agent_id == scope.agent_id, AgentWorkspace.status.in_((AgentWorkingResourceStatus.ACTIVE, AgentWorkingResourceStatus.RETIRED)),
) )
) ).all()
if row is None: for workspace in workspaces:
row = WorkflowAgentRuntimeSession( if workspace.status == AgentWorkingResourceStatus.RETIRED:
tenant_id=scope.tenant_id, retired.append(workspace.id)
app_id=scope.app_id, continue
owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN, workspace_id = AgentWorkspaceService.retire_workspace(
workflow_id=scope.workflow_id, session=session,
workflow_run_id=scope.workflow_run_id, tenant_id=tenant_id,
node_id=scope.node_id, workspace_id=workspace.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,
backend_run_id=backend_run_id,
session_snapshot=snapshot_json,
composition_layer_specs=specs_json,
status=WorkflowAgentRuntimeSessionStatus.ACTIVE,
pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id,
) )
session.add(row) if workspace_id is not None:
else: retired.append(workspace_id)
row.node_execution_id = scope.node_execution_id
row.agent_config_snapshot_id = scope.agent_config_snapshot_id
row.backend_run_id = backend_run_id
row.session_snapshot = snapshot_json
row.composition_layer_specs = specs_json
row.status = WorkflowAgentRuntimeSessionStatus.ACTIVE
row.cleaned_at = None
# Set (or clear, when omitted) the ask_human pause correlation.
row.pending_form_id = pending_form_id
row.pending_tool_call_id = pending_tool_call_id
session.commit() session.commit()
return retired
def mark_cleaned(self, *, scope: WorkflowAgentSessionScope, backend_run_id: str | None = None) -> None: @staticmethod
if scope.workflow_run_id is None: def _load_execution(*, session: Session, scope: WorkflowAgentSessionScope) -> WorkflowNodeExecutionModel:
return return WorkflowAgentWorkspaceStore._load_execution_by_identity(
session=session,
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,
)
with session_factory.create_session() as session: @staticmethod
row = session.scalar( def _load_execution_by_identity(
select(WorkflowAgentRuntimeSession).where( *,
WorkflowAgentRuntimeSession.tenant_id == scope.tenant_id, session: Session,
WorkflowAgentRuntimeSession.workflow_run_id == scope.workflow_run_id, tenant_id: str,
WorkflowAgentRuntimeSession.node_id == scope.node_id, app_id: str,
WorkflowAgentRuntimeSession.binding_id == scope.binding_id, workflow_id: str,
WorkflowAgentRuntimeSession.agent_id == scope.agent_id, workflow_run_id: str | None,
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE, node_id: str,
) node_execution_id: str,
) ) -> WorkflowNodeExecutionModel:
if row is None: """Wait briefly for the already-emitted node-start event to persist its caller row."""
return
if backend_run_id is not None: stmt = select(WorkflowNodeExecutionModel).where(
row.backend_run_id = backend_run_id WorkflowNodeExecutionModel.id == node_execution_id,
row.status = WorkflowAgentRuntimeSessionStatus.CLEANED WorkflowNodeExecutionModel.tenant_id == tenant_id,
row.cleaned_at = naive_utc_now() WorkflowNodeExecutionModel.app_id == app_id,
session.commit() WorkflowNodeExecutionModel.workflow_id == workflow_id,
WorkflowNodeExecutionModel.node_id == node_id,
WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id,
)
for attempt in range(_CALLER_VISIBILITY_ATTEMPTS):
execution = session.scalar(stmt)
if execution is not None:
return execution
if attempt < _CALLER_VISIBILITY_ATTEMPTS - 1:
time.sleep(_CALLER_VISIBILITY_INTERVAL_SECONDS)
raise AgentWorkspaceNotFoundError("Workflow node execution caller is unavailable")
@staticmethod
def _stored(scope: WorkflowAgentSessionScope, binding: AgentWorkspaceBinding) -> StoredWorkflowAgentSession:
snapshot = (
CompositorSessionSnapshot.model_validate_json(binding.session_snapshot)
if binding.session_snapshot
else None
)
return StoredWorkflowAgentSession(
scope=scope,
binding_id=binding.id,
workspace_id=binding.workspace_id,
backend_binding_ref=binding.backend_binding_ref,
session_snapshot=snapshot,
pending_form_id=binding.pending_form_id,
pending_tool_call_id=binding.pending_tool_call_id,
)
__all__ = [ __all__ = ["StoredWorkflowAgentSession", "WorkflowAgentSessionScope", "WorkflowAgentWorkspaceStore"]
"StoredWorkflowAgentSession",
"WorkflowAgentRuntimeSessionStore",
"WorkflowAgentSessionScope",
]
@@ -0,0 +1,89 @@
"""Retire Workflow Agent Workspaces when the Workflow Run terminates."""
from __future__ import annotations
import logging
from typing import override
from core.app.entities.app_invoke_entities import DifyRunContext
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentWorkspaceStore
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import (
GraphEngineEvent,
GraphRunAbortedEvent,
GraphRunFailedEvent,
GraphRunPartialSucceededEvent,
GraphRunSucceededEvent,
)
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__)
class WorkflowAgentWorkspaceRetirementLayer(GraphEngineLayer):
"""Synchronously retire run Workspaces, then enqueue physical collection."""
_TERMINAL_EVENTS = (
GraphRunSucceededEvent,
GraphRunPartialSucceededEvent,
GraphRunFailedEvent,
GraphRunAbortedEvent,
)
def __init__(
self,
*,
dify_run_context: DifyRunContext,
) -> None:
super().__init__()
self._dify_run_context = dify_run_context
@override
def on_graph_start(self) -> None:
return
@override
def on_event(self, event: GraphEngineEvent) -> None:
if not isinstance(event, self._TERMINAL_EVENTS):
return
workflow_run_id = get_system_text(
self.graph_runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
)
if not workflow_run_id:
logger.warning("Skipping Workflow Agent Workspace retirement: workflow_run_id is missing")
return
try:
workspace_ids = WorkflowAgentWorkspaceStore().retire_workflow_run(
tenant_id=self._dify_run_context.tenant_id,
app_id=self._dify_run_context.app_id,
workflow_run_id=workflow_run_id,
)
except Exception:
logger.exception(
"Failed to retire Workflow Agent Workspaces",
extra={
"tenant_id": self._dify_run_context.tenant_id,
"app_id": self._dify_run_context.app_id,
"workflow_run_id": workflow_run_id,
},
)
return
enqueue_agent_resource_collection(
tenant_id=self._dify_run_context.tenant_id,
workspace_ids=workspace_ids,
)
@override
def on_graph_end(self, error: Exception | None) -> None:
return
def build_workflow_agent_workspace_retirement_layer(
*, dify_run_context: DifyRunContext
) -> WorkflowAgentWorkspaceRetirementLayer:
return WorkflowAgentWorkspaceRetirementLayer(dify_run_context=dify_run_context)
__all__ = ["WorkflowAgentWorkspaceRetirementLayer", "build_workflow_agent_workspace_retirement_layer"]
+1
View File
@@ -166,6 +166,7 @@ def init_app(app: DifyApp) -> Celery:
imports = [ imports = [
"tasks.async_workflow_tasks", # trigger workers "tasks.async_workflow_tasks", # trigger workers
"tasks.collect_agent_resources_task", # retired Agent resource collection
"tasks.trigger_processing_tasks", # async trigger processing "tasks.trigger_processing_tasks", # async trigger processing
"tasks.generate_summary_index_task", # summary index generation "tasks.generate_summary_index_task", # summary index generation
"tasks.regenerate_summary_index_task", # summary index regeneration "tasks.regenerate_summary_index_task", # summary index regeneration
@@ -277,6 +277,12 @@ class LogstoreWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
logger.exception("Failed to dual-write node execution to SQL database: id=%s", execution.id) logger.exception("Failed to dual-write node execution to SQL database: id=%s", execution.id)
# Don't raise - LogStore write succeeded, SQL is just a backup # Don't raise - LogStore write succeeded, SQL is just a backup
@override
def save_synchronously(self, execution: WorkflowNodeExecution) -> None:
"""Create the SQL caller row required by Agent v2 participant ownership."""
self.sql_repository.save_synchronously(execution)
@override @override
def save_execution_data(self, execution: WorkflowNodeExecution) -> None: def save_execution_data(self, execution: WorkflowNodeExecution) -> None:
""" """
+1 -1
View File
@@ -34,7 +34,7 @@ class _SessionResponseSource[SourceT]:
self._session = session self._session = session
def __getattr__(self, name: str) -> object: def __getattr__(self, name: str) -> object:
return getattr(self._source, name) # noqa: no-new-getattr response adapter delegates model fields return getattr(self._source, name) # guard-ignore: no-new-getattr -- delegates model fields
class _FeedbackResponseSource(_SessionResponseSource[MessageFeedback]): class _FeedbackResponseSource(_SessionResponseSource[MessageFeedback]):
+1 -1
View File
@@ -227,7 +227,7 @@ class DatasetDetailResponseSource:
return self.dataset.get_total_available_documents(session=self.session) return self.dataset.get_total_available_documents(session=self.session)
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
return getattr(self.dataset, name) # noqa: no-new-getattr response adapter delegates model fields return getattr(self.dataset, name) # guard-ignore: no-new-getattr -- delegates model fields
def dataset_detail_response_source(dataset: Any, *, session: Session) -> DatasetDetailResponseSource: def dataset_detail_response_source(dataset: Any, *, session: Session) -> DatasetDetailResponseSource:
+1 -1
View File
@@ -90,7 +90,7 @@ class DocumentWithSession:
return self.document.get_doc_metadata_details(session=self.session) return self.document.get_doc_metadata_details(session=self.session)
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
return getattr(self.document, name) # noqa: no-new-getattr response adapter delegates model fields return getattr(self.document, name) # guard-ignore: no-new-getattr -- delegates model fields
def document_response(document: Document, *, session: Session) -> DocumentResponse: def document_response(document: Document, *, session: Session) -> DocumentResponse:
@@ -0,0 +1,64 @@
"""add agent home snapshot ledger
Revision ID: 2f39536b3feb
Revises: 6f5a9c2d8e1b
Create Date: 2026-07-21 22:51:07.268658
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2f39536b3feb'
down_revision = '6f5a9c2d8e1b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('agent_home_snapshots',
sa.Column('id', models.types.StringUUID(), nullable=False),
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('agent_id', models.types.StringUUID(), nullable=False),
sa.Column('snapshot_ref', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name='agent_home_snapshot_pkey')
)
with op.batch_alter_table('agent_home_snapshots', schema=None) as batch_op:
batch_op.create_index('agent_home_snapshot_tenant_agent_idx', ['tenant_id', 'agent_id'], unique=False)
with op.batch_alter_table('agent_config_drafts', schema=None) as batch_op:
batch_op.add_column(sa.Column('home_snapshot_id', models.types.StringUUID(), nullable=False))
with op.batch_alter_table('agent_config_snapshots', schema=None) as batch_op:
batch_op.add_column(sa.Column('home_snapshot_id', models.types.StringUUID(), nullable=False))
with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op:
batch_op.add_column(sa.Column('home_snapshot_id', models.types.StringUUID(), nullable=False))
batch_op.drop_index(batch_op.f('agent_runtime_session_conversation_scope_unique'), postgresql_where='(conversation_id IS NOT NULL)')
batch_op.create_index('agent_runtime_session_conversation_scope_unique', ['tenant_id', 'conversation_id', 'agent_id', 'agent_config_snapshot_id', 'home_snapshot_id'], unique=True, postgresql_where=sa.text('conversation_id IS NOT NULL'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op:
batch_op.drop_index('agent_runtime_session_conversation_scope_unique', postgresql_where=sa.text('conversation_id IS NOT NULL'))
batch_op.create_index(batch_op.f('agent_runtime_session_conversation_scope_unique'), ['tenant_id', 'conversation_id', 'agent_id', 'agent_config_snapshot_id'], unique=True, postgresql_where='(conversation_id IS NOT NULL)')
batch_op.drop_column('home_snapshot_id')
with op.batch_alter_table('agent_config_snapshots', schema=None) as batch_op:
batch_op.drop_column('home_snapshot_id')
with op.batch_alter_table('agent_config_drafts', schema=None) as batch_op:
batch_op.drop_column('home_snapshot_id')
with op.batch_alter_table('agent_home_snapshots', schema=None) as batch_op:
batch_op.drop_index('agent_home_snapshot_tenant_agent_idx')
op.drop_table('agent_home_snapshots')
# ### end Alembic commands ###
@@ -0,0 +1,153 @@
"""replace agent runtime sessions with workspaces and bindings
Revision ID: f6e4c5686857
Revises: 2f39536b3feb
Create Date: 2026-07-23 02:03:05.641638
"""
from alembic import op
import models as models
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'f6e4c5686857'
down_revision = '2f39536b3feb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('agent_workspace_bindings',
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('app_id', models.types.StringUUID(), nullable=False),
sa.Column('workspace_id', models.types.StringUUID(), nullable=False),
sa.Column('agent_id', models.types.StringUUID(), nullable=False),
sa.Column('base_home_snapshot_id', models.types.StringUUID(), nullable=False),
sa.Column('agent_config_version_id', models.types.StringUUID(), nullable=False),
sa.Column('agent_config_version_kind', sa.String(length=32), nullable=False),
sa.Column('backend_binding_ref', sa.String(length=255), nullable=False),
sa.Column('session_snapshot', models.types.LongText(), nullable=True),
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
sa.Column('retired_at', sa.DateTime(), nullable=True),
sa.Column('pending_form_id', models.types.StringUUID(), nullable=True),
sa.Column('pending_tool_call_id', sa.String(length=255), nullable=True),
sa.Column('id', models.types.StringUUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name='agent_workspace_binding_pkey')
)
with op.batch_alter_table('agent_workspace_bindings', schema=None) as batch_op:
batch_op.create_index('agent_workspace_binding_agent_status_idx', ['tenant_id', 'agent_id', 'status'], unique=False)
batch_op.create_index('agent_workspace_binding_status_retired_idx', ['status', 'retired_at'], unique=False)
batch_op.create_index('agent_workspace_binding_workspace_status_idx', ['tenant_id', 'workspace_id', 'status'], unique=False)
op.create_table('agent_workspaces',
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('app_id', models.types.StringUUID(), nullable=False),
sa.Column('owner_type', sa.String(length=32), nullable=False),
sa.Column('owner_id', models.types.StringUUID(), nullable=False),
sa.Column('owner_scope_key', sa.String(length=255), nullable=False),
sa.Column('backend_workspace_ref', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
sa.Column('active_guard', sa.SmallInteger(), server_default='1', nullable=True),
sa.Column('retired_at', sa.DateTime(), nullable=True),
sa.Column('id', models.types.StringUUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name='agent_workspace_pkey')
)
with op.batch_alter_table('agent_workspaces', schema=None) as batch_op:
batch_op.create_index('agent_workspace_owner_active_unique', ['tenant_id', 'owner_type', 'owner_id', 'owner_scope_key', 'active_guard'], unique=True)
batch_op.create_index('agent_workspace_status_retired_idx', ['status', 'retired_at'], unique=False)
batch_op.create_index('agent_workspace_tenant_app_status_idx', ['tenant_id', 'app_id', 'status'], unique=False)
batch_op.create_index('agent_workspace_tenant_status_idx', ['tenant_id', 'status'], unique=False)
with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('agent_runtime_session_backend_run_idx'))
batch_op.drop_index(batch_op.f('agent_runtime_session_conversation_lookup_idx'))
batch_op.drop_index(batch_op.f('agent_runtime_session_conversation_scope_unique'), postgresql_where='(conversation_id IS NOT NULL)')
batch_op.drop_index(batch_op.f('agent_runtime_session_workflow_lookup_idx'))
batch_op.drop_index(batch_op.f('agent_runtime_session_workflow_scope_unique'), postgresql_where='(workflow_run_id IS NOT NULL)')
op.drop_table('agent_runtime_sessions')
with op.batch_alter_table('agent_home_snapshots', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=32), server_default='active', nullable=False))
batch_op.add_column(sa.Column('retired_at', sa.DateTime(), nullable=True))
batch_op.create_index('agent_home_snapshot_status_retired_idx', ['status', 'retired_at'], unique=False)
with op.batch_alter_table('conversations', schema=None) as batch_op:
batch_op.add_column(sa.Column('agent_workspace_binding_id', models.types.StringUUID(), nullable=True))
with op.batch_alter_table('agent_config_drafts', schema=None) as batch_op:
batch_op.add_column(sa.Column('agent_workspace_binding_id', models.types.StringUUID(), nullable=True))
with op.batch_alter_table('workflow_node_executions', schema=None) as batch_op:
batch_op.add_column(sa.Column('agent_workspace_binding_id', models.types.StringUUID(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('workflow_node_executions', schema=None) as batch_op:
batch_op.drop_column('agent_workspace_binding_id')
with op.batch_alter_table('agent_config_drafts', schema=None) as batch_op:
batch_op.drop_column('agent_workspace_binding_id')
with op.batch_alter_table('conversations', schema=None) as batch_op:
batch_op.drop_column('agent_workspace_binding_id')
with op.batch_alter_table('agent_home_snapshots', schema=None) as batch_op:
batch_op.drop_index('agent_home_snapshot_status_retired_idx')
batch_op.drop_column('retired_at')
batch_op.drop_column('status')
op.create_table('agent_runtime_sessions',
sa.Column('id', sa.UUID(), server_default=sa.text('uuidv7()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('app_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('owner_type', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
sa.Column('agent_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('backend_run_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('session_snapshot', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('workflow_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('workflow_run_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('node_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('node_execution_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('binding_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('agent_config_snapshot_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('composition_layer_specs', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('conversation_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('status', sa.VARCHAR(length=32), server_default=sa.text("'active'::character varying"), autoincrement=False, nullable=False),
sa.Column('cleaned_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=False),
sa.Column('pending_form_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('pending_tool_call_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('home_snapshot_id', sa.UUID(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('agent_runtime_session_pkey'))
)
with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op:
batch_op.create_index(batch_op.f('agent_runtime_session_workflow_scope_unique'), ['tenant_id', 'workflow_run_id', 'node_id', 'binding_id', 'agent_id'], unique=True, postgresql_where='(workflow_run_id IS NOT NULL)')
batch_op.create_index(batch_op.f('agent_runtime_session_workflow_lookup_idx'), ['tenant_id', 'workflow_run_id', 'node_id', 'status'], unique=False)
batch_op.create_index(batch_op.f('agent_runtime_session_conversation_scope_unique'), ['tenant_id', 'conversation_id', 'agent_id', 'agent_config_snapshot_id', 'home_snapshot_id'], unique=True, postgresql_where='(conversation_id IS NOT NULL)')
batch_op.create_index(batch_op.f('agent_runtime_session_conversation_lookup_idx'), ['tenant_id', 'conversation_id', 'status'], unique=False)
batch_op.create_index(batch_op.f('agent_runtime_session_backend_run_idx'), ['backend_run_id'], unique=False)
with op.batch_alter_table('agent_workspaces', schema=None) as batch_op:
batch_op.drop_index('agent_workspace_tenant_status_idx')
batch_op.drop_index('agent_workspace_tenant_app_status_idx')
batch_op.drop_index('agent_workspace_status_retired_idx')
batch_op.drop_index('agent_workspace_owner_active_unique')
op.drop_table('agent_workspaces')
with op.batch_alter_table('agent_workspace_bindings', schema=None) as batch_op:
batch_op.drop_index('agent_workspace_binding_workspace_status_idx')
batch_op.drop_index('agent_workspace_binding_status_retired_idx')
batch_op.drop_index('agent_workspace_binding_agent_status_idx')
op.drop_table('agent_workspace_bindings')
# ### end Alembic commands ###
+12 -10
View File
@@ -15,21 +15,22 @@ from .agent import (
AgentConfigRevision, AgentConfigRevision,
AgentConfigRevisionOperation, AgentConfigRevisionOperation,
AgentConfigSnapshot, AgentConfigSnapshot,
AgentConfigVersionKind,
AgentDebugConversation, AgentDebugConversation,
AgentDriveFile, AgentDriveFile,
AgentDriveFileKind, AgentDriveFileKind,
AgentHomeSnapshot,
AgentIconType, AgentIconType,
AgentKind, AgentKind,
AgentRuntimeSession,
AgentRuntimeSessionOwnerType,
AgentRuntimeSessionStatus,
AgentScope, AgentScope,
AgentSource, AgentSource,
AgentStatus, AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspace,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
WorkflowAgentBindingType, WorkflowAgentBindingType,
WorkflowAgentNodeBinding, WorkflowAgentNodeBinding,
WorkflowAgentRuntimeSession,
WorkflowAgentRuntimeSessionStatus,
) )
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
from .comment import ( from .comment import (
@@ -164,17 +165,20 @@ __all__ = [
"AgentConfigRevision", "AgentConfigRevision",
"AgentConfigRevisionOperation", "AgentConfigRevisionOperation",
"AgentConfigSnapshot", "AgentConfigSnapshot",
"AgentConfigVersionKind",
"AgentDebugConversation", "AgentDebugConversation",
"AgentDriveFile", "AgentDriveFile",
"AgentDriveFileKind", "AgentDriveFileKind",
"AgentHomeSnapshot",
"AgentIconType", "AgentIconType",
"AgentKind", "AgentKind",
"AgentRuntimeSession",
"AgentRuntimeSessionOwnerType",
"AgentRuntimeSessionStatus",
"AgentScope", "AgentScope",
"AgentSource", "AgentSource",
"AgentStatus", "AgentStatus",
"AgentWorkingResourceStatus",
"AgentWorkspace",
"AgentWorkspaceBinding",
"AgentWorkspaceOwnerType",
"ApiRequest", "ApiRequest",
"ApiToken", "ApiToken",
"ApiToolProvider", "ApiToolProvider",
@@ -271,8 +275,6 @@ __all__ = [
"Workflow", "Workflow",
"WorkflowAgentBindingType", "WorkflowAgentBindingType",
"WorkflowAgentNodeBinding", "WorkflowAgentNodeBinding",
"WorkflowAgentRuntimeSession",
"WorkflowAgentRuntimeSessionStatus",
"WorkflowAppLog", "WorkflowAppLog",
"WorkflowAppLogCreatedFrom", "WorkflowAppLogCreatedFrom",
"WorkflowArchiveLog", "WorkflowArchiveLog",
+114 -104
View File
@@ -116,35 +116,29 @@ class WorkflowAgentBindingType(StrEnum):
INLINE_AGENT = "inline_agent" INLINE_AGENT = "inline_agent"
class AgentRuntimeSessionStatus(StrEnum): class AgentWorkingResourceStatus(StrEnum):
"""Lifecycle state of an Agent backend session snapshot. """Product lifecycle state for a persistent working-environment resource."""
Owner-agnostic: applies both to workflow Agent Node runs (owner =
workflow_run) and to Agent App conversations (owner = conversation).
"""
# Snapshot can be reused by a later Agent run in the same session.
ACTIVE = "active" ACTIVE = "active"
# Snapshot has been retired and must not be submitted to Agent backend again. RETIRED = "retired"
CLEANED = "cleaned"
class AgentRuntimeSessionOwnerType(StrEnum): class AgentWorkspaceOwnerType(StrEnum):
"""Which product surface owns an Agent runtime session row.""" """Product scope that owns a Workspace."""
# Owned by one workflow Agent Node execution scope.
WORKFLOW_RUN = "workflow_run" WORKFLOW_RUN = "workflow_run"
# Owned by one Agent App conversation (multi-turn chat).
CONVERSATION = "conversation" CONVERSATION = "conversation"
BUILD_DRAFT = "build_draft"
# Back-compat alias: the workflow lifecycle code (shipped in PR #36724) imports class AgentConfigVersionKind(StrEnum):
# the old name. Kept so unifying the table does not churn that path. SNAPSHOT = "snapshot"
WorkflowAgentRuntimeSessionStatus = AgentRuntimeSessionStatus DRAFT = "draft"
BUILD_DRAFT = "build_draft"
class Agent(DefaultFieldsMixin, Base): class Agent(DefaultFieldsMixin, Base):
"""Workspace-scoped Agent identity used by Agent Roster and workflow-only agents.""" """Agent Soul and source lineage; ``AgentWorkspaceBinding.id`` identifies each materialized participant."""
__tablename__ = "agents" __tablename__ = "agents"
__table_args__ = ( __table_args__ = (
@@ -221,14 +215,42 @@ class Agent(DefaultFieldsMixin, Base):
archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class AgentDebugConversation(DefaultFieldsMixin, Base): class AgentHomeSnapshot(Base):
"""Per-account, per-draft console debug conversation for an Agent App. """Append-only mapping from one Agent-owned Home identity to its backend ref.
Agent App preview state must be isolated by editor account. The Agent row is Product tables reference ``id``. ``snapshot_ref`` remains an opaque
shared by everyone in the workspace, so this table owns the user-specific deployment-specific handle and is only consumed at Dify Agent boundaries.
conversation pointers used by console debug chat. ``draft`` is the Preview Snapshot bytes and ``snapshot_ref`` are immutable. Lifecycle metadata can
conversation and ``debug_build`` is the Build conversation; they must never transition ACTIVE -> RETIRED; successful physical collection deletes row.
share persisted messages or runtime sessions. """
__tablename__ = "agent_home_snapshots"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_home_snapshot_pkey"),
Index("agent_home_snapshot_tenant_agent_idx", "tenant_id", "agent_id"),
Index("agent_home_snapshot_status_retired_idx", "status", "retired_at"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
snapshot_ref: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[AgentWorkingResourceStatus] = mapped_column(
EnumText(AgentWorkingResourceStatus, length=32),
nullable=False,
default=AgentWorkingResourceStatus.ACTIVE,
server_default=AgentWorkingResourceStatus.ACTIVE.value,
)
retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
class AgentDebugConversation(DefaultFieldsMixin, Base):
"""Current console Conversation pointer for one account and draft surface.
This row owns no Binding or runtime. A Preview Conversation holds its
CONVERSATION Binding pointer, while a DEBUG_BUILD AgentConfigDraft holds its
BUILD_DRAFT Binding pointer.
""" """
__tablename__ = "agent_debug_conversations" __tablename__ = "agent_debug_conversations"
@@ -259,7 +281,11 @@ class AgentDebugConversation(DefaultFieldsMixin, Base):
class AgentConfigDraft(DefaultFieldsMixin, Base): class AgentConfigDraft(DefaultFieldsMixin, Base):
"""Editable Agent Soul draft separated from immutable published snapshots.""" """Editable Agent Soul draft separated from immutable published snapshots.
A DEBUG_BUILD draft owns its materialized participant through
``agent_workspace_binding_id``. Normal drafts leave that pointer unset.
"""
__tablename__ = "agent_config_drafts" __tablename__ = "agent_config_drafts"
__table_args__ = ( __table_args__ = (
@@ -281,6 +307,8 @@ class AgentConfigDraft(DefaultFieldsMixin, Base):
account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="") draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="")
base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
home_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_workspace_binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False) config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
@@ -316,6 +344,7 @@ class AgentConfigSnapshot(DefaultFieldsMixin, Base):
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
version: Mapped[int] = mapped_column(sa.Integer, nullable=False) version: Mapped[int] = mapped_column(sa.Integer, nullable=False)
config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False) config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False)
home_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
summary: Mapped[str | None] = mapped_column(LongText, nullable=True) summary: Mapped[str | None] = mapped_column(LongText, nullable=True)
version_note: Mapped[str | None] = mapped_column(LongText, nullable=True) version_note: Mapped[str | None] = mapped_column(LongText, nullable=True)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
@@ -432,102 +461,83 @@ class WorkflowAgentNodeBinding(DefaultFieldsMixin, Base):
return dict(self.node_job_config) return dict(self.node_job_config)
class AgentRuntimeSession(DefaultFieldsMixin, Base): class AgentWorkspace(DefaultFieldsMixin, Base):
"""Persisted Agent backend session snapshot, owner-agnostic. """Mutable Workspace owned by one product scope, independent of Agents."""
One unified table serves both owners (decision Q2): __tablename__ = "agent_workspaces"
- workflow Agent Node runs: ``owner_type = workflow_run``; the
``workflow_id / workflow_run_id / node_id / binding_id /
agent_config_snapshot_id / composition_layer_specs`` columns are set.
- Agent App conversations: ``owner_type = conversation``; the
``conversation_id`` column is set and the workflow columns stay NULL.
Runtime state is scoped by ``agent_config_snapshot_id``. For published
web/API runs this points to an immutable AgentConfigSnapshot; for console
debugger/build runs it points to the editable AgentConfigDraft row.
The snapshot is runtime state returned by Agent backend, kept separate from
Agent Soul snapshots and workflow node-job config.
"""
__tablename__ = "agent_runtime_sessions"
__table_args__ = ( __table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_runtime_session_pkey"), sa.PrimaryKeyConstraint("id", name="agent_workspace_pkey"),
# Workflow owner uniqueness (partial: only rows with a workflow_run_id).
Index( Index(
"agent_runtime_session_workflow_scope_unique", "agent_workspace_owner_active_unique",
"tenant_id", "tenant_id",
"workflow_run_id", "owner_type",
"node_id", "owner_id",
"binding_id", "owner_scope_key",
"agent_id", "active_guard",
unique=True, unique=True,
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
), ),
# Conversation owner uniqueness (partial: only rows with a conversation_id). Index("agent_workspace_tenant_status_idx", "tenant_id", "status"),
Index( Index("agent_workspace_tenant_app_status_idx", "tenant_id", "app_id", "status"),
"agent_runtime_session_conversation_scope_unique", Index("agent_workspace_status_retired_idx", "status", "retired_at"),
"tenant_id",
"conversation_id",
"agent_id",
"agent_config_snapshot_id",
unique=True,
postgresql_where=sa.text("conversation_id IS NOT NULL"),
),
Index(
"agent_runtime_session_workflow_lookup_idx",
"tenant_id",
"workflow_run_id",
"node_id",
"status",
),
Index(
"agent_runtime_session_conversation_lookup_idx",
"tenant_id",
"conversation_id",
"status",
),
Index("agent_runtime_session_backend_run_idx", "backend_run_id"),
) )
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
owner_type: Mapped[AgentRuntimeSessionOwnerType] = mapped_column( owner_type: Mapped[AgentWorkspaceOwnerType] = mapped_column(
EnumText(AgentRuntimeSessionOwnerType, length=32), nullable=False EnumText(AgentWorkspaceOwnerType, length=32), nullable=False
) )
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) owner_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
backend_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True) owner_scope_key: Mapped[str] = mapped_column(String(255), nullable=False)
session_snapshot: Mapped[str] = mapped_column(LongText, nullable=False) backend_workspace_ref: Mapped[str] = mapped_column(String(255), nullable=False)
# Workflow-owner columns (NULL for conversation owner). status: Mapped[AgentWorkingResourceStatus] = mapped_column(
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) EnumText(AgentWorkingResourceStatus, length=32),
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
agent_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
# JSON-encoded list of non-sensitive runtime layer specs ({name, type, deps,
# config}). The persisted schema keeps its original name because the sandbox
# refactor intentionally avoids a storage migration.
composition_layer_specs: Mapped[str] = mapped_column(LongText, nullable=False, server_default="[]")
# Conversation-owner column (NULL for workflow owner).
conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
status: Mapped[AgentRuntimeSessionStatus] = mapped_column(
EnumText(AgentRuntimeSessionStatus, length=32),
nullable=False, nullable=False,
default=AgentRuntimeSessionStatus.ACTIVE, default=AgentWorkingResourceStatus.ACTIVE,
server_default=AgentWorkingResourceStatus.ACTIVE.value,
) )
cleaned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) active_guard: Mapped[int | None] = mapped_column(sa.SmallInteger, nullable=True, default=1, server_default="1")
# ENG-637: when a run pauses for a dify.ask_human deferred call, these link retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# the session to the awaiting HITL form and the deferred tool_call_id, so a
# resumed node can map the submitted form back into deferred_tool_results.
# Both NULL whenever the session is not paused on human input. class AgentWorkspaceBinding(DefaultFieldsMixin, Base):
"""One materialized Agent participant and session attached to a Workspace.
All resource IDs are logical associations rather than database foreign
keys, so RETIRED rows can outlive their Workspace or base Home Snapshot.
``agent_id`` identifies the source Agent Soul; this row's ``id`` identifies
the participant and its private Materialized Home.
"""
__tablename__ = "agent_workspace_bindings"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_workspace_binding_pkey"),
Index("agent_workspace_binding_workspace_status_idx", "tenant_id", "workspace_id", "status"),
Index("agent_workspace_binding_agent_status_idx", "tenant_id", "agent_id", "status"),
Index("agent_workspace_binding_status_retired_idx", "status", "retired_at"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workspace_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
base_home_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_config_version_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_config_version_kind: Mapped[AgentConfigVersionKind] = mapped_column(
EnumText(AgentConfigVersionKind, length=32), nullable=False
)
backend_binding_ref: Mapped[str] = mapped_column(String(255), nullable=False)
session_snapshot: Mapped[str | None] = mapped_column(LongText, nullable=True)
status: Mapped[AgentWorkingResourceStatus] = mapped_column(
EnumText(AgentWorkingResourceStatus, length=32),
nullable=False,
default=AgentWorkingResourceStatus.ACTIVE,
server_default=AgentWorkingResourceStatus.ACTIVE.value,
)
retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
pending_form_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) pending_form_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
pending_tool_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True) pending_tool_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Back-compat alias for the shipped workflow lifecycle code (PR #36724).
WorkflowAgentRuntimeSession = AgentRuntimeSession
class AgentDriveFileKind(StrEnum): class AgentDriveFileKind(StrEnum):
"""Kind of existing file record an agent-drive KV entry points at.""" """Kind of existing file record an agent-drive KV entry points at."""
+8
View File
@@ -1160,6 +1160,13 @@ class OAuthProviderApp(TypeBase):
class Conversation(Base): class Conversation(Base):
"""Conversation state, including the exact Agent participant when applicable.
``agent_workspace_binding_id`` is a logical pointer rather than a foreign
key because retired Binding ledger rows may be collected before the
conversation history is deleted.
"""
__tablename__ = "conversations" __tablename__ = "conversations"
__table_args__ = ( __table_args__ = (
sa.PrimaryKeyConstraint("id", name="conversation_pkey"), sa.PrimaryKeyConstraint("id", name="conversation_pkey"),
@@ -1181,6 +1188,7 @@ class Conversation(Base):
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
app_id = mapped_column(StringUUID, nullable=False) app_id = mapped_column(StringUUID, nullable=False)
app_model_config_id = mapped_column(StringUUID, nullable=True) app_model_config_id = mapped_column(StringUUID, nullable=True)
agent_workspace_binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
model_provider = mapped_column(String(255), nullable=True) model_provider = mapped_column(String(255), nullable=True)
override_model_configs = mapped_column(LongText) override_model_configs = mapped_column(LongText)
model_id = mapped_column(String(255), nullable=True) model_id = mapped_column(String(255), nullable=True)
+1
View File
@@ -1031,6 +1031,7 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
node_id: Mapped[str] = mapped_column(String(255)) node_id: Mapped[str] = mapped_column(String(255))
node_type: Mapped[str] = mapped_column(String(255)) node_type: Mapped[str] = mapped_column(String(255))
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255))
agent_workspace_binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
inputs: Mapped[str | None] = mapped_column(LongText) inputs: Mapped[str | None] = mapped_column(LongText)
process_data: Mapped[str | None] = mapped_column(LongText) process_data: Mapped[str | None] = mapped_column(LongText)
outputs: Mapped[str | None] = mapped_column(LongText) outputs: Mapped[str | None] = mapped_column(LongText)
+11 -20
View File
@@ -964,12 +964,6 @@ Stop a running Agent App chat message generation
| ---- | ---------- | ----------- | -------- | ------ | | ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) | | agent_id | path | | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| No | **application/json**: [AgentDebugConversationRefreshPayload](#agentdebugconversationrefreshpayload)<br> |
#### Responses #### Responses
| Code | Description | Schema | | Code | Description | Schema |
@@ -1261,7 +1255,8 @@ Get basic information for an Agent App conversation sandbox
| Name | Located in | Description | Required | Schema | | Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ | | ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) | | agent_id | path | Agent ID | Yes | string (uuid) |
| conversation_id | query | Agent App conversation ID | Yes | string | | caller_id | query | Agent App caller ID | Yes | string |
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
#### Responses #### Responses
@@ -1277,7 +1272,8 @@ List a directory in an Agent App conversation sandbox
| Name | Located in | Description | Required | Schema | | Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ | | ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) | | agent_id | path | Agent ID | Yes | string (uuid) |
| conversation_id | query | Agent App conversation ID | Yes | string | | caller_id | query | Agent App caller ID | Yes | string |
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
| path | query | Directory path relative to the sandbox workspace | No | string, <br>**Default:** . | | path | query | Directory path relative to the sandbox workspace | No | string, <br>**Default:** . |
#### Responses #### Responses
@@ -1294,7 +1290,8 @@ Read a text/binary preview file in an Agent App conversation sandbox
| Name | Located in | Description | Required | Schema | | Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ | | ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) | | agent_id | path | Agent ID | Yes | string (uuid) |
| conversation_id | query | Agent App conversation ID | Yes | string | | caller_id | query | Agent App caller ID | Yes | string |
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
| path | query | File path relative to the sandbox workspace | Yes | string | | path | query | File path relative to the sandbox workspace | Yes | string |
#### Responses #### Responses
@@ -3807,7 +3804,7 @@ List a directory in a workflow Agent node sandbox
| app_id | path | Application ID | Yes | string (uuid) | | app_id | path | Application ID | Yes | string (uuid) |
| node_id | path | Workflow Agent node ID | Yes | string | | node_id | path | Workflow Agent node ID | Yes | string |
| workflow_run_id | path | Workflow run ID | Yes | string (uuid) | | workflow_run_id | path | Workflow run ID | Yes | string (uuid) |
| node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | string | | node_execution_id | query | Workflow node execution ID | Yes | string |
| path | query | Directory path relative to the sandbox workspace | No | string, <br>**Default:** . | | path | query | Directory path relative to the sandbox workspace | No | string, <br>**Default:** . |
#### Responses #### Responses
@@ -3826,7 +3823,7 @@ Read a text/binary preview file in a workflow Agent node sandbox
| app_id | path | Application ID | Yes | string (uuid) | | app_id | path | Application ID | Yes | string (uuid) |
| node_id | path | Workflow Agent node ID | Yes | string | | node_id | path | Workflow Agent node ID | Yes | string |
| workflow_run_id | path | Workflow run ID | Yes | string (uuid) | | workflow_run_id | path | Workflow run ID | Yes | string (uuid) |
| node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | string | | node_execution_id | query | Workflow node execution ID | Yes | string |
| path | query | File path relative to the sandbox workspace | Yes | string | | path | query | File path relative to the sandbox workspace | Yes | string |
#### Responses #### Responses
@@ -13943,12 +13940,6 @@ Stable Agent Soul reference to one normalized skill archive.
| date | string | | Yes | | date | string | | Yes |
| message_count | integer | | Yes | | message_count | integer | | Yes |
#### AgentDebugConversationRefreshPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | Agent draft surface whose conversation should be refreshed | No |
#### AgentDebugConversationRefreshResponse #### AgentDebugConversationRefreshResponse
| Name | Type | Description | Required | | Name | Type | Description | Required |
@@ -14666,7 +14657,8 @@ section may be empty, which is how callers express "no knowledge layer".
| Name | Type | Description | Required | | Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- | | ---- | ---- | ----------- | -------- |
| conversation_id | string | Agent App conversation ID | Yes | | caller_id | string | Agent App caller ID | Yes |
| caller_type | string, <br>**Available values:** "build_draft", "conversation" | *Enum:* `"build_draft"`, `"conversation"` | Yes |
| path | string | File path relative to the sandbox workspace | Yes | | path | string | File path relative to the sandbox workspace | Yes |
#### AgentScope #### AgentScope
@@ -21347,7 +21339,6 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| Name | Type | Description | Required | | Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- | | ---- | ---- | ----------- | -------- |
| session_id | string | | Yes |
| workspace_cwd | string | | Yes | | workspace_cwd | string | | Yes |
#### SandboxListResponse #### SandboxListResponse
@@ -23167,7 +23158,7 @@ How a workflow node is bound to an Agent.
| Name | Type | Description | Required | | Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- | | ---- | ---- | ----------- | -------- |
| node_execution_id | string | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | | node_execution_id | string | Workflow node execution ID | Yes |
| path | string | File path relative to the sandbox workspace | Yes | | path | string | File path relative to the sandbox workspace | Yes |
#### WorkflowAppLogPaginationResponse #### WorkflowAppLogPaginationResponse
@@ -316,10 +316,10 @@ class OracleVector(BaseVector):
entities.append(current_entity) entities.append(current_entity)
else: else:
try: try:
nltk.data.find("tokenizers/punkt") nltk.data.find("tokenizers/punkt_tab")
nltk.data.find("corpora/stopwords") nltk.data.find("corpora/stopwords")
except LookupError: except LookupError:
raise LookupError("Unable to find the required NLTK data package: punkt and stopwords") raise LookupError("Unable to find the required NLTK data package: punkt_tab and stopwords")
e_str = re.sub(r"[^\w ]", "", query) e_str = re.sub(r"[^\w ]", "", query)
all_tokens = nltk.word_tokenize(e_str) all_tokens = nltk.word_tokenize(e_str)
stop_words = stopwords.words("english") stop_words = stopwords.words("english")
+1 -1
View File
@@ -206,7 +206,7 @@ storage = [
############################################################ ############################################################
# [ Tools ] dependency group # [ Tools ] dependency group
############################################################ ############################################################
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.9.1,<4.0.0"] tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.0,<4.0.0"]
############################################################ ############################################################
# [ VDB ] workspace plugins — hollow packages under providers/vdb/* # [ VDB ] workspace plugins — hollow packages under providers/vdb/*
+365 -32
View File
@@ -9,7 +9,7 @@ from sqlalchemy.sql.elements import ColumnElement
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
from libs.helper import to_timestamp from libs.helper import to_timestamp
from models import Account from models import Account, Conversation
from models.agent import ( from models.agent import (
APP_BACKED_AGENT_SOURCES, APP_BACKED_AGENT_SOURCES,
Agent, Agent,
@@ -18,12 +18,15 @@ from models.agent import (
AgentConfigRevision, AgentConfigRevision,
AgentConfigRevisionOperation, AgentConfigRevisionOperation,
AgentConfigSnapshot, AgentConfigSnapshot,
AgentConfigVersionKind,
AgentDebugConversation,
AgentDriveFile, AgentDriveFile,
AgentIconType, AgentIconType,
AgentKind, AgentKind,
AgentScope, AgentScope,
AgentSource, AgentSource,
AgentStatus, AgentStatus,
AgentWorkspaceOwnerType,
WorkflowAgentBindingType, WorkflowAgentBindingType,
WorkflowAgentNodeBinding, WorkflowAgentNodeBinding,
) )
@@ -35,6 +38,7 @@ from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import ( from services.agent.errors import (
AgentBuildSandboxNotFoundError,
AgentModelNotConfiguredError, AgentModelNotConfiguredError,
AgentNameConflictError, AgentNameConflictError,
AgentNotFoundError, AgentNotFoundError,
@@ -42,11 +46,17 @@ from services.agent.errors import (
AgentVersionNotFoundError, AgentVersionNotFoundError,
InvalidComposerConfigError, InvalidComposerConfigError,
) )
from services.agent.home_snapshot_service import (
AgentHomeSnapshotService,
validate_home_snapshot_binding,
)
from services.agent.knowledge_datasets import ( from services.agent.knowledge_datasets import (
get_tenant_knowledge_dataset_rows, get_tenant_knowledge_dataset_rows,
list_missing_tenant_knowledge_dataset_ids, list_missing_tenant_knowledge_dataset_ids,
) )
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.agent.roster_service import AgentRosterService from services.agent.roster_service import AgentRosterService
from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentWorkspaceService, WorkspaceOwnerScope
from services.app_service import AppService, CreateAppParams from services.app_service import AppService, CreateAppParams
from services.entities.agent_entities import ( from services.entities.agent_entities import (
AgentSoulConfig, AgentSoulConfig,
@@ -56,6 +66,7 @@ from services.entities.agent_entities import (
ComposerVariant, ComposerVariant,
WorkflowNodeJobConfig, WorkflowNodeJobConfig,
) )
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
# WorkflowAgentNodeBinding.workflow_version tag for the draft workflow row. # WorkflowAgentNodeBinding.workflow_version tag for the draft workflow row.
# Mirrors Workflow.version when it is "draft" (see models/workflow.py). # Mirrors Workflow.version when it is "draft" (see models/workflow.py).
@@ -198,6 +209,18 @@ class AgentComposerService:
binding = cls._get_workflow_binding( binding = cls._get_workflow_binding(
session=session, tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id session=session, tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id
) )
retirement_candidates = (
{binding.agent_id}
if binding is not None
and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
and binding.agent_id
and payload.save_strategy
in {
ComposerSaveStrategy.SAVE_AS_NEW_AGENT,
ComposerSaveStrategy.SAVE_TO_ROSTER,
}
else set()
)
match payload.save_strategy: match payload.save_strategy:
case ComposerSaveStrategy.NODE_JOB_ONLY: case ComposerSaveStrategy.NODE_JOB_ONLY:
@@ -232,7 +255,11 @@ class AgentComposerService:
) )
case ComposerSaveStrategy.SAVE_TO_ROSTER: case ComposerSaveStrategy.SAVE_TO_ROSTER:
binding = cls._save_to_roster( binding = cls._save_to_roster(
session=session, tenant_id=tenant_id, account_id=account_id, binding=binding, payload=payload session=session,
tenant_id=tenant_id,
account_id=account_id,
binding=binding,
payload=payload,
) )
session.flush() session.flush()
@@ -257,6 +284,17 @@ class AgentComposerService:
payload=payload, payload=payload,
agent_id=binding.agent_id, agent_id=binding.agent_id,
) )
session.commit()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=tenant_id,
agent_ids=retirement_candidates,
account_id=account_id,
)
enqueue_agent_resource_collection(
tenant_id=tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return state return state
@classmethod @classmethod
@@ -390,12 +428,10 @@ class AgentComposerService:
@classmethod @classmethod
def _load_agent_composer_for_agent(cls, *, session: Session, tenant_id: str, agent: Agent) -> dict[str, Any]: def _load_agent_composer_for_agent(cls, *, session: Session, tenant_id: str, agent: Agent) -> dict[str, Any]:
draft = cls._get_or_create_agent_draft( draft = cls.get_or_create_normal_agent_draft(
session=session, session=session,
tenant_id=tenant_id, tenant_id=tenant_id,
agent=agent, agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
created_by=agent.updated_by or agent.created_by, created_by=agent.updated_by or agent.created_by,
) )
version = cls._get_version_if_present( version = cls._get_version_if_present(
@@ -417,7 +453,34 @@ class AgentComposerService:
@classmethod @classmethod
def save_agent_app_composer( def save_agent_app_composer(
cls, *, session: Session, tenant_id: str, app_id: str, account_id: str, payload: ComposerSavePayload cls,
*,
session: Session,
tenant_id: str,
app_id: str,
account_id: str,
payload: ComposerSavePayload,
) -> dict[str, Any]:
try:
return cls._save_agent_app_composer_impl(
session=session,
tenant_id=tenant_id,
app_id=app_id,
account_id=account_id,
payload=payload,
)
except IntegrityError as exc:
raise AgentNameConflictError() from exc
@classmethod
def _save_agent_app_composer_impl(
cls,
*,
session: Session,
tenant_id: str,
app_id: str,
account_id: str,
payload: ComposerSavePayload,
) -> dict[str, Any]: ) -> dict[str, Any]:
if payload.variant != ComposerVariant.AGENT_APP: if payload.variant != ComposerVariant.AGENT_APP:
raise ValueError("Agent App composer endpoint only accepts agent_app variant") raise ValueError("Agent App composer endpoint only accepts agent_app variant")
@@ -446,11 +509,25 @@ class AgentComposerService:
updated_by=account_id, updated_by=account_id,
) )
session.add(agent) session.add(agent)
try: session.flush()
session.flush() home_snapshot = AgentHomeSnapshotService.create_initial(
except IntegrityError as exc: session=session,
session.rollback() tenant_id=tenant_id,
raise AgentNameConflictError() from exc agent_id=agent.id,
)
initial_version = cls._create_config_version(
session=session,
tenant_id=tenant_id,
agent_id=agent.id,
account_id=account_id,
agent_soul=AgentSoulConfig(),
operation=AgentConfigRevisionOperation.CREATE_VERSION,
version_note=None,
home_snapshot_id=home_snapshot.id,
)
agent.active_config_snapshot_id = initial_version.id
agent.active_config_has_model = False
agent.active_config_is_published = False
return cls._save_agent_composer_for_agent( return cls._save_agent_composer_for_agent(
session=session, session=session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -488,7 +565,7 @@ class AgentComposerService:
) -> dict[str, Any]: ) -> dict[str, Any]:
if payload.agent_soul is None: if payload.agent_soul is None:
raise ValueError("agent_soul is required") raise ValueError("agent_soul is required")
cls._save_agent_draft( draft = cls._save_agent_draft(
session=session, session=session,
tenant_id=tenant_id, tenant_id=tenant_id,
agent=agent, agent=agent,
@@ -503,6 +580,7 @@ class AgentComposerService:
tenant_id=tenant_id, tenant_id=tenant_id,
agent=agent, agent=agent,
agent_soul=payload.agent_soul, agent_soul=payload.agent_soul,
home_snapshot_id=draft.home_snapshot_id,
) )
session.flush() session.flush()
@@ -523,6 +601,7 @@ class AgentComposerService:
tenant_id: str, tenant_id: str,
agent: Agent, agent: Agent,
agent_soul: AgentSoulConfig, agent_soul: AgentSoulConfig,
home_snapshot_id: str,
) -> bool: ) -> bool:
if not agent.active_config_snapshot_id: if not agent.active_config_snapshot_id:
return False return False
@@ -538,7 +617,9 @@ class AgentComposerService:
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent): if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
return False return False
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict) return home_snapshot_id == active_version.home_snapshot_id and _agent_soul_config_json(
agent_soul
) == _agent_soul_config_json(active_version.config_snapshot_dict)
@classmethod @classmethod
def publish_agent_app_draft( def publish_agent_app_draft(
@@ -567,6 +648,11 @@ class AgentComposerService:
if not agent_soul_has_model(agent_soul): if not agent_soul_has_model(agent_soul):
raise AgentModelNotConfiguredError() raise AgentModelNotConfiguredError()
cls.validate_knowledge_datasets(session=session, tenant_id=tenant_id, agent_soul=agent_soul) cls.validate_knowledge_datasets(session=session, tenant_id=tenant_id, agent_soul=agent_soul)
validate_home_snapshot_binding(
session=session,
agent=agent,
home_snapshot_id=draft.home_snapshot_id,
)
version = cls._create_config_version( version = cls._create_config_version(
session=session, session=session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -576,6 +662,7 @@ class AgentComposerService:
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
version_note=version_note, version_note=version_note,
previous_snapshot_id=agent.active_config_snapshot_id, previous_snapshot_id=agent.active_config_snapshot_id,
home_snapshot_id=draft.home_snapshot_id,
) )
agent.active_config_snapshot_id = version.id agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(agent_soul) agent.active_config_has_model = agent_soul_has_model(agent_soul)
@@ -595,6 +682,29 @@ class AgentComposerService:
def checkout_agent_app_build_draft( def checkout_agent_app_build_draft(
cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str, force: bool = False cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str, force: bool = False
) -> dict[str, Any]: ) -> dict[str, Any]:
try:
result, retired_binding_id = cls._checkout_agent_app_build_draft_in_transaction(
session=session,
tenant_id=tenant_id,
agent_id=agent_id,
account_id=account_id,
force=force,
)
session.commit()
except Exception:
session.rollback()
raise
if retired_binding_id is not None:
enqueue_agent_resource_collection(
tenant_id=tenant_id,
binding_ids=(retired_binding_id,),
)
return result
@classmethod
def _checkout_agent_app_build_draft_in_transaction(
cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str, force: bool
) -> tuple[dict[str, Any], str | None]:
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id) agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id)
normal_draft = cls._get_or_create_agent_draft( normal_draft = cls._get_or_create_agent_draft(
session=session, session=session,
@@ -612,7 +722,23 @@ class AgentComposerService:
account_id=account_id, account_id=account_id,
) )
if build_draft is not None and not force: if build_draft is not None and not force:
return cls._serialize_build_draft_state(build_draft) return cls._serialize_build_draft_state(build_draft), None
retired_binding_id: str | None = None
if build_draft is not None and build_draft.agent_workspace_binding_id is not None:
cls._validate_active_build_draft_binding(
session=session,
tenant_id=tenant_id,
agent=agent,
build_draft=build_draft,
)
retired_binding_id = AgentWorkspaceService.retire_binding(
session=session,
tenant_id=tenant_id,
binding_id=build_draft.agent_workspace_binding_id,
)
if retired_binding_id is None:
raise AgentBuildSandboxNotFoundError()
build_draft.agent_workspace_binding_id = None
if build_draft is None: if build_draft is None:
build_draft = AgentConfigDraft( build_draft = AgentConfigDraft(
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -624,10 +750,44 @@ class AgentComposerService:
) )
session.add(build_draft) session.add(build_draft)
build_draft.base_snapshot_id = normal_draft.base_snapshot_id build_draft.base_snapshot_id = normal_draft.base_snapshot_id
build_draft.home_snapshot_id = normal_draft.home_snapshot_id
build_draft.config_snapshot = AgentSoulConfig.model_validate(normal_draft.config_snapshot_dict) build_draft.config_snapshot = AgentSoulConfig.model_validate(normal_draft.config_snapshot_dict)
build_draft.updated_by = account_id build_draft.updated_by = account_id
session.flush() session.flush()
return cls._serialize_build_draft_state(build_draft) return cls._serialize_build_draft_state(build_draft), retired_binding_id
@classmethod
def _validate_active_build_draft_binding(
cls,
*,
session: Session,
tenant_id: str,
agent: Agent,
build_draft: AgentConfigDraft,
) -> None:
binding_id = build_draft.agent_workspace_binding_id
runtime_app_id = AgentRosterService.runtime_backing_app_id(agent)
if binding_id is None or runtime_app_id is None:
raise AgentBuildSandboxNotFoundError()
binding = AgentWorkspaceService.get_active_binding(
session=session,
tenant_id=tenant_id,
binding_id=binding_id,
expected_owner_scope=WorkspaceOwnerScope(
tenant_id=tenant_id,
app_id=runtime_app_id,
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
owner_id=build_draft.id,
),
)
if binding is None or binding.agent_id != agent.id:
raise AgentBuildSandboxNotFoundError()
AgentWorkspaceService.validate_binding_generation(
binding,
base_home_snapshot_id=build_draft.home_snapshot_id,
agent_config_version_id=build_draft.id,
agent_config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
)
@classmethod @classmethod
def load_agent_app_build_draft( def load_agent_app_build_draft(
@@ -669,6 +829,29 @@ class AgentComposerService:
def apply_agent_app_build_draft( def apply_agent_app_build_draft(
cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str
) -> dict[str, Any]: ) -> dict[str, Any]:
try:
result, retired_binding_ids = cls._apply_agent_app_build_draft_in_transaction(
session=session,
tenant_id=tenant_id,
agent_id=agent_id,
account_id=account_id,
)
session.commit()
except Exception:
session.rollback()
raise
enqueue_agent_resource_collection(tenant_id=tenant_id, binding_ids=retired_binding_ids)
return result
@classmethod
def _apply_agent_app_build_draft_in_transaction(
cls,
*,
session: Session,
tenant_id: str,
agent_id: str,
account_id: str,
) -> tuple[dict[str, Any], list[str]]:
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id) agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id)
build_draft = cls._get_agent_draft( build_draft = cls._get_agent_draft(
session=session, session=session,
@@ -680,6 +863,21 @@ class AgentComposerService:
if build_draft is None: if build_draft is None:
raise AgentVersionNotFoundError() raise AgentVersionNotFoundError()
applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict) applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict)
ComposerConfigValidator.validate_publish_payload(
ComposerSavePayload(
variant=ComposerVariant.AGENT_APP,
agent_soul=applied_agent_soul,
save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
)
)
cls.validate_knowledge_datasets(session=session, tenant_id=tenant_id, agent_soul=applied_agent_soul)
source_binding_id = build_draft.agent_workspace_binding_id
if source_binding_id is None:
raise AgentBuildSandboxNotFoundError()
home_snapshot = AgentHomeSnapshotService.create_for_build_apply(
session=session,
build_draft=build_draft,
)
normal_draft = cls._save_agent_draft( normal_draft = cls._save_agent_draft(
session=session, session=session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -690,32 +888,145 @@ class AgentComposerService:
account_id_for_audit=account_id, account_id_for_audit=account_id,
base_snapshot_id=build_draft.base_snapshot_id, base_snapshot_id=build_draft.base_snapshot_id,
) )
retired_binding_ids = cls._retire_normal_preview_bindings(
session=session,
tenant_id=tenant_id,
agent=agent,
normal_draft=normal_draft,
)
normal_draft.home_snapshot_id = home_snapshot.id
agent.active_config_is_published = cls._agent_soul_matches_active_config( agent.active_config_is_published = cls._agent_soul_matches_active_config(
session=session, session=session,
tenant_id=tenant_id, tenant_id=tenant_id,
agent=agent, agent=agent,
agent_soul=applied_agent_soul, agent_soul=applied_agent_soul,
home_snapshot_id=home_snapshot.id,
) )
agent.updated_by = account_id agent.updated_by = account_id
retired_binding_id = AgentWorkspaceService.retire_binding(
session=session,
tenant_id=tenant_id,
binding_id=source_binding_id,
)
if retired_binding_id is None:
raise AgentBuildSandboxNotFoundError()
retired_binding_ids.append(source_binding_id)
session.delete(build_draft) session.delete(build_draft)
session.flush() return {"result": "success", "draft": cls._serialize_draft(normal_draft)}, retired_binding_ids
return {"result": "success", "draft": cls._serialize_draft(normal_draft)}
@classmethod
def _retire_normal_preview_bindings(
cls,
*,
session: Session,
tenant_id: str,
agent: Agent,
normal_draft: AgentConfigDraft,
) -> list[str]:
"""Retire Preview participants before Build Apply replaces the shared Draft Home."""
mappings = session.scalars(
select(AgentDebugConversation).where(
AgentDebugConversation.tenant_id == tenant_id,
AgentDebugConversation.agent_id == agent.id,
AgentDebugConversation.draft_type == AgentConfigDraftType.DRAFT,
)
).all()
retired_binding_ids: list[str] = []
for mapping in mappings:
conversation = session.scalar(
select(Conversation).where(
Conversation.id == mapping.conversation_id,
Conversation.app_id == mapping.app_id,
Conversation.is_deleted.is_(False),
)
)
if conversation is None or conversation.agent_workspace_binding_id is None:
continue
binding_id = conversation.agent_workspace_binding_id
binding = AgentWorkspaceService.get_active_binding(
session=session,
tenant_id=tenant_id,
binding_id=binding_id,
expected_owner_scope=WorkspaceOwnerScope(
tenant_id=tenant_id,
app_id=mapping.app_id,
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id=conversation.id,
),
)
if binding is None or binding.agent_id != agent.id:
raise AgentWorkspaceNotFoundError("Agent Preview participant Binding is unavailable")
AgentWorkspaceService.validate_binding_generation(
binding,
base_home_snapshot_id=normal_draft.home_snapshot_id,
agent_config_version_id=normal_draft.id,
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
)
retired_binding_id = AgentWorkspaceService.retire_binding(
session=session,
tenant_id=tenant_id,
binding_id=binding_id,
)
if retired_binding_id is None:
raise AgentWorkspaceNotFoundError("Agent Preview participant Binding is unavailable")
conversation.agent_workspace_binding_id = None
retired_binding_ids.append(binding_id)
return retired_binding_ids
@classmethod @classmethod
def discard_agent_app_build_draft( def discard_agent_app_build_draft(
cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str
) -> dict[str, Any]: ) -> dict[str, Any]:
try:
result, retired_binding_id = cls._discard_agent_app_build_draft_in_transaction(
session=session,
tenant_id=tenant_id,
agent_id=agent_id,
account_id=account_id,
)
session.commit()
except Exception:
session.rollback()
raise
if retired_binding_id is not None:
enqueue_agent_resource_collection(
tenant_id=tenant_id,
binding_ids=(retired_binding_id,),
)
return result
@classmethod
def _discard_agent_app_build_draft_in_transaction(
cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str
) -> tuple[dict[str, Any], str | None]:
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id)
build_draft = cls._get_agent_draft( build_draft = cls._get_agent_draft(
session=session, session=session,
tenant_id=tenant_id, tenant_id=tenant_id,
agent_id=agent_id, agent_id=agent.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD, draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id, account_id=account_id,
) )
if build_draft is not None: if build_draft is None:
session.delete(build_draft) return {"result": "success"}, None
session.flush() retired_binding_id: str | None = None
return {"result": "success"} if build_draft.agent_workspace_binding_id is not None:
cls._validate_active_build_draft_binding(
session=session,
tenant_id=tenant_id,
agent=agent,
build_draft=build_draft,
)
retired_binding_id = AgentWorkspaceService.retire_binding(
session=session,
tenant_id=tenant_id,
binding_id=build_draft.agent_workspace_binding_id,
)
if retired_binding_id is None:
raise AgentBuildSandboxNotFoundError()
session.delete(build_draft)
return {"result": "success"}, retired_binding_id
@classmethod @classmethod
def collect_validation_findings( def collect_validation_findings(
@@ -1279,6 +1590,12 @@ class AgentComposerService:
binding = cls._require_binding(binding) binding = cls._require_binding(binding)
if not binding.agent_id or payload.agent_soul is None: if not binding.agent_id or payload.agent_soul is None:
raise ValueError("agent_id and agent_soul are required") raise ValueError("agent_id and agent_soul are required")
current_snapshot = cls._require_version(
session=session,
tenant_id=tenant_id,
agent_id=binding.agent_id,
version_id=binding.current_snapshot_id,
)
version = cls._create_config_version( version = cls._create_config_version(
session=session, session=session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -1287,6 +1604,7 @@ class AgentComposerService:
agent_soul=payload.agent_soul, agent_soul=payload.agent_soul,
operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION, operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION,
version_note=payload.version_note, version_note=payload.version_note,
home_snapshot_id=current_snapshot.home_snapshot_id,
) )
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=binding.agent_id) agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=binding.agent_id)
agent.active_config_snapshot_id = version.id agent.active_config_snapshot_id = version.id
@@ -1449,6 +1767,11 @@ class AgentComposerService:
) )
session.add(agent) session.add(agent)
session.flush() session.flush()
home_snapshot = AgentHomeSnapshotService.create_initial(
session=session,
tenant_id=tenant_id,
agent_id=agent.id,
)
version = cls._create_config_version( version = cls._create_config_version(
session=session, session=session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -1457,6 +1780,7 @@ class AgentComposerService:
agent_soul=agent_soul, agent_soul=agent_soul,
operation=AgentConfigRevisionOperation.CREATE_VERSION, operation=AgentConfigRevisionOperation.CREATE_VERSION,
version_note=None, version_note=None,
home_snapshot_id=home_snapshot.id,
) )
agent.active_config_snapshot_id = version.id agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(agent_soul) agent.active_config_has_model = agent_soul_has_model(agent_soul)
@@ -1590,7 +1914,6 @@ class AgentComposerService:
session=session, session=session,
) )
except IntegrityError as exc: except IntegrityError as exc:
session.rollback()
raise AgentNameConflictError() from exc raise AgentNameConflictError() from exc
agent = AgentRosterService(session).get_app_backing_agent(tenant_id=tenant_id, app_id=app.id) agent = AgentRosterService(session).get_app_backing_agent(tenant_id=tenant_id, app_id=app.id)
@@ -1628,6 +1951,7 @@ class AgentComposerService:
agent_soul: AgentSoulConfig, agent_soul: AgentSoulConfig,
operation: AgentConfigRevisionOperation, operation: AgentConfigRevisionOperation,
version_note: str | None, version_note: str | None,
home_snapshot_id: str,
previous_snapshot_id: str | None = None, previous_snapshot_id: str | None = None,
) -> AgentConfigSnapshot: ) -> AgentConfigSnapshot:
next_version = ( next_version = (
@@ -1644,6 +1968,7 @@ class AgentComposerService:
agent_id=agent_id, agent_id=agent_id,
version=next_version, version=next_version,
config_snapshot=agent_soul, config_snapshot=agent_soul,
home_snapshot_id=home_snapshot_id,
version_note=version_note, version_note=version_note,
created_by=account_id, created_by=account_id,
) )
@@ -1683,6 +2008,7 @@ class AgentComposerService:
operation=operation, operation=operation,
version_note=version_note, version_note=version_note,
previous_snapshot_id=current_snapshot.id, previous_snapshot_id=current_snapshot.id,
home_snapshot_id=current_snapshot.home_snapshot_id,
) )
@classmethod @classmethod
@@ -1749,7 +2075,10 @@ class AgentComposerService:
agent: Agent, agent: Agent,
created_by: str | None, created_by: str | None,
) -> AgentConfigDraft: ) -> AgentConfigDraft:
"""Resolve the shared Preview draft, rebasing inline agents when needed.""" """Resolve the normal Draft, rebasing only stale WORKFLOW_ONLY DRAFT rows whose account_id is None.
Roster and DEBUG_BUILD Drafts are never rebased.
"""
return cls._get_or_create_agent_draft( return cls._get_or_create_agent_draft(
session=session, session=session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -1767,6 +2096,8 @@ class AgentComposerService:
snapshot: AgentConfigSnapshot, snapshot: AgentConfigSnapshot,
updated_by: str | None, updated_by: str | None,
) -> bool: ) -> bool:
"""Sync a stale normal Draft's base_snapshot_id, home_snapshot_id, config_snapshot, and updated_by."""
if ( if (
agent.scope != AgentScope.WORKFLOW_ONLY agent.scope != AgentScope.WORKFLOW_ONLY
or draft.draft_type != AgentConfigDraftType.DRAFT or draft.draft_type != AgentConfigDraftType.DRAFT
@@ -1777,6 +2108,7 @@ class AgentComposerService:
): ):
return False return False
draft.base_snapshot_id = snapshot.id draft.base_snapshot_id = snapshot.id
draft.home_snapshot_id = snapshot.home_snapshot_id
draft.config_snapshot = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) draft.config_snapshot = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
draft.updated_by = updated_by draft.updated_by = updated_by
return True return True
@@ -1813,7 +2145,9 @@ class AgentComposerService:
agent_id=agent.id, agent_id=agent.id,
version_id=agent.active_config_snapshot_id, version_id=agent.active_config_snapshot_id,
) )
if active_snapshot is not None and cls._rebase_workflow_only_normal_draft( if active_snapshot is None:
raise AgentVersionNotFoundError()
if cls._rebase_workflow_only_normal_draft(
agent=agent, agent=agent,
draft=draft, draft=draft,
snapshot=active_snapshot, snapshot=active_snapshot,
@@ -1827,18 +2161,17 @@ class AgentComposerService:
agent_id=agent.id, agent_id=agent.id,
version_id=agent.active_config_snapshot_id, version_id=agent.active_config_snapshot_id,
) )
agent_soul = ( if base_snapshot is None:
AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict) raise AgentVersionNotFoundError()
if base_snapshot is not None agent_soul = AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict)
else AgentSoulConfig()
)
draft = AgentConfigDraft( draft = AgentConfigDraft(
tenant_id=tenant_id, tenant_id=tenant_id,
agent_id=agent.id, agent_id=agent.id,
draft_type=draft_type, draft_type=draft_type,
account_id=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None, account_id=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
draft_owner_key=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD and account_id else "", draft_owner_key=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD and account_id else "",
base_snapshot_id=base_snapshot.id if base_snapshot else None, base_snapshot_id=base_snapshot.id,
home_snapshot_id=base_snapshot.home_snapshot_id,
config_snapshot=agent_soul, config_snapshot=agent_soul,
created_by=created_by, created_by=created_by,
updated_by=created_by, updated_by=created_by,
@@ -2140,7 +2473,7 @@ class AgentComposerService:
from services.agent.roster_service import AgentRosterService from services.agent.roster_service import AgentRosterService
return AgentRosterService(session).get_or_create_agent_app_debug_conversation_id( return AgentRosterService(session).get_or_create_build_conversation(
tenant_id=tenant_id, tenant_id=tenant_id,
agent_id=agent.id, agent_id=agent.id,
account_id=account_id, account_id=account_id,
+15 -2
View File
@@ -49,6 +49,7 @@ from services.agent.dsl_entities import (
make_portable_agent_package, make_portable_agent_package,
portable_ref, portable_ref,
) )
from services.agent.home_snapshot_service import AgentHomeSnapshotService
from services.agent.knowledge_datasets import get_tenant_knowledge_dataset_rows from services.agent.knowledge_datasets import get_tenant_knowledge_dataset_rows
from services.agent.roster_service import AgentRosterService from services.agent.roster_service import AgentRosterService
from services.entities.dsl_entities import DslImportWarning from services.entities.dsl_entities import DslImportWarning
@@ -224,6 +225,7 @@ class AgentDslService:
account_id=None, account_id=None,
draft_owner_key="", draft_owner_key="",
base_snapshot_id=snapshot.id, base_snapshot_id=snapshot.id,
home_snapshot_id=snapshot.home_snapshot_id,
config_snapshot=soul, config_snapshot=soul,
created_by=account.id, created_by=account.id,
updated_by=account.id, updated_by=account.id,
@@ -243,7 +245,7 @@ class AgentDslService:
portable_graph: Mapping[str, Any], portable_graph: Mapping[str, Any],
raw_packages: Mapping[str, Any], raw_packages: Mapping[str, Any],
account: Account, account: Account,
) -> tuple[dict[str, Any], list[DslImportWarning]]: ) -> tuple[dict[str, Any], list[DslImportWarning], set[str]]:
"""Materialize every packaged Agent as a node-owned inline Agent.""" """Materialize every packaged Agent as a node-owned inline Agent."""
graph = copy.deepcopy(dict(portable_graph)) graph = copy.deepcopy(dict(portable_graph))
@@ -256,6 +258,11 @@ class AgentDslService:
WorkflowAgentNodeBinding.workflow_version == Workflow.VERSION_DRAFT, WorkflowAgentNodeBinding.workflow_version == Workflow.VERSION_DRAFT,
) )
).all() ).all()
retirement_candidates = {
binding.agent_id
for binding in previous_bindings
if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and binding.agent_id
}
for binding in previous_bindings: for binding in previous_bindings:
self.session.delete(binding) self.session.delete(binding)
self.session.flush() self.session.flush()
@@ -312,7 +319,7 @@ class AgentDslService:
workflow.graph = json.dumps(graph) workflow.graph = json.dumps(graph)
self.session.flush() self.session.flush()
return graph, warnings return graph, warnings, retirement_candidates
def clone_inline_binding_for_node( def clone_inline_binding_for_node(
self, self,
@@ -562,11 +569,17 @@ class AgentDslService:
) )
or 0 or 0
) + 1 ) + 1
home_snapshot = AgentHomeSnapshotService.create_initial(
session=self.session,
tenant_id=tenant_id,
agent_id=agent.id,
)
snapshot = AgentConfigSnapshot( snapshot = AgentConfigSnapshot(
tenant_id=tenant_id, tenant_id=tenant_id,
agent_id=agent.id, agent_id=agent.id,
version=next_version, version=next_version,
config_snapshot=soul, config_snapshot=soul,
home_snapshot_id=home_snapshot.id,
created_by=account_id, created_by=account_id,
) )
self.session.add(snapshot) self.session.add(snapshot)
+6
View File
@@ -29,6 +29,12 @@ class AgentModelNotConfiguredError(BaseHTTPException):
code = 400 code = 400
class AgentBuildSandboxNotFoundError(BaseHTTPException):
error_code = "agent_build_sandbox_not_found"
description = "The retained Build Sandbox is no longer available."
code = 404
class AgentSoulLockedError(BadRequest): class AgentSoulLockedError(BadRequest):
description = "Agent Soul is locked for this workflow node." description = "Agent Soul is locked for this workflow node."
+238
View File
@@ -0,0 +1,238 @@
"""Own immutable Agent Home Snapshot ledger rows and physical collection."""
from __future__ import annotations
import logging
from dify_agent.client import Client, DifyAgentNotFoundError
from dify_agent.protocol import CreateHomeSnapshotFromBindingRequest, InitializeHomeSnapshotRequest
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from libs.uuid_utils import uuidv7
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigSnapshot,
AgentConfigVersionKind,
AgentHomeSnapshot,
AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspaceOwnerType,
)
from services.agent.errors import AgentBuildSandboxNotFoundError
from services.agent.workspace_service import AgentWorkspaceService, WorkspaceOwnerScope
logger = logging.getLogger(__name__)
class AgentHomeSnapshotUnavailableError(RuntimeError):
"""The requested owner-scoped Home Snapshot cannot be used."""
class AgentHomeSnapshotService:
"""Create, retire, and collect Agent-owned immutable Home Snapshots."""
@classmethod
def create_initial(
cls,
*,
session: Session,
tenant_id: str,
agent_id: str,
) -> AgentHomeSnapshot:
home_snapshot_id = str(uuidv7())
with cls._client() as client:
response = client.initialize_home_snapshot_sync(
InitializeHomeSnapshotRequest(
tenant_id=tenant_id,
agent_id=agent_id,
home_snapshot_id=home_snapshot_id,
)
)
home_snapshot = AgentHomeSnapshot(
id=home_snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
snapshot_ref=response.snapshot_ref,
status=AgentWorkingResourceStatus.ACTIVE,
)
session.add(home_snapshot)
session.flush()
return home_snapshot
@classmethod
def create_for_build_apply(
cls,
*,
session: Session,
build_draft: AgentConfigDraft,
) -> AgentHomeSnapshot:
"""Checkpoint the exact participant owned by ``build_draft``."""
source_binding_id = build_draft.agent_workspace_binding_id
if source_binding_id is None:
raise AgentBuildSandboxNotFoundError()
agent = session.scalar(
select(Agent).where(
Agent.id == build_draft.agent_id,
Agent.tenant_id == build_draft.tenant_id,
)
)
if agent is None:
raise AgentBuildSandboxNotFoundError()
from services.agent.roster_service import AgentRosterService
runtime_app_id = AgentRosterService.runtime_backing_app_id(agent)
if runtime_app_id is None:
raise AgentBuildSandboxNotFoundError()
binding = AgentWorkspaceService.get_active_binding(
session=session,
tenant_id=build_draft.tenant_id,
binding_id=source_binding_id,
expected_owner_scope=WorkspaceOwnerScope(
tenant_id=build_draft.tenant_id,
app_id=runtime_app_id,
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
owner_id=build_draft.id,
),
)
if binding is None or binding.agent_id != build_draft.agent_id:
raise AgentBuildSandboxNotFoundError()
AgentWorkspaceService.validate_binding_generation(
binding,
base_home_snapshot_id=build_draft.home_snapshot_id,
agent_config_version_id=build_draft.id,
agent_config_version_kind=AgentConfigVersionKind.BUILD_DRAFT,
)
home_snapshot_id = str(uuidv7())
try:
with cls._client() as client:
response = client.create_home_snapshot_from_binding_sync(
CreateHomeSnapshotFromBindingRequest(
tenant_id=build_draft.tenant_id,
agent_id=build_draft.agent_id,
home_snapshot_id=home_snapshot_id,
backend_binding_ref=binding.backend_binding_ref,
)
)
except DifyAgentNotFoundError as exc:
raise AgentBuildSandboxNotFoundError() from exc
home_snapshot = AgentHomeSnapshot(
id=home_snapshot_id,
tenant_id=build_draft.tenant_id,
agent_id=build_draft.agent_id,
snapshot_ref=response.snapshot_ref,
status=AgentWorkingResourceStatus.ACTIVE,
)
session.add(home_snapshot)
return home_snapshot
@classmethod
def retire_all_for_agent(cls, *, session: Session, tenant_id: str, agent_id: str) -> list[str]:
rows = session.scalars(
select(AgentHomeSnapshot).where(
AgentHomeSnapshot.tenant_id == tenant_id,
AgentHomeSnapshot.agent_id == agent_id,
AgentHomeSnapshot.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
now = naive_utc_now()
for row in rows:
row.status = AgentWorkingResourceStatus.RETIRED
row.retired_at = now
return [row.id for row in rows]
@classmethod
def collect_retired_home_snapshot(cls, *, tenant_id: str, home_snapshot_id: str) -> None:
try:
cls._collect_retired_home_snapshot(tenant_id=tenant_id, home_snapshot_id=home_snapshot_id)
except Exception:
logger.exception(
"Failed to collect retired Agent Home Snapshot",
extra={"tenant_id": tenant_id, "home_snapshot_id": home_snapshot_id},
)
@classmethod
def _collect_retired_home_snapshot(cls, *, tenant_id: str, home_snapshot_id: str) -> None:
with session_factory.create_session() as session:
snapshot = session.scalar(
select(AgentHomeSnapshot).where(
AgentHomeSnapshot.id == home_snapshot_id,
AgentHomeSnapshot.tenant_id == tenant_id,
AgentHomeSnapshot.status == AgentWorkingResourceStatus.RETIRED,
)
)
if snapshot is None:
return
referenced = session.scalar(
select(AgentConfigDraft.id).where(AgentConfigDraft.home_snapshot_id == home_snapshot_id).limit(1)
) or session.scalar(
select(AgentConfigSnapshot.id).where(AgentConfigSnapshot.home_snapshot_id == home_snapshot_id).limit(1)
)
if referenced is not None:
return
snapshot_ref = snapshot.snapshot_ref
try:
cls.delete(snapshot_ref=snapshot_ref)
except Exception:
logger.exception(
"Failed to collect retired Agent Home Snapshot",
extra={"tenant_id": tenant_id, "home_snapshot_id": home_snapshot_id},
)
return
with session_factory.create_session() as session:
snapshot = session.scalar(
select(AgentHomeSnapshot).where(
AgentHomeSnapshot.id == home_snapshot_id,
AgentHomeSnapshot.tenant_id == tenant_id,
AgentHomeSnapshot.status == AgentWorkingResourceStatus.RETIRED,
)
)
if snapshot is not None:
session.delete(snapshot)
session.commit()
@classmethod
def delete(cls, *, snapshot_ref: str) -> None:
with cls._client() as client:
client.delete_home_snapshot_sync(snapshot_ref)
@staticmethod
def _client() -> Client:
base_url = dify_config.AGENT_BACKEND_BASE_URL
if not base_url:
raise AgentHomeSnapshotUnavailableError("Dify Agent backend is required for Home Snapshot operations")
return Client(base_url=base_url)
def validate_home_snapshot_binding(*, session: Session, agent: Agent, home_snapshot_id: str) -> None:
_require_owned_home_snapshot(session=session, agent=agent, home_snapshot_id=home_snapshot_id)
def _require_owned_home_snapshot(*, session: Session, agent: Agent, home_snapshot_id: str) -> AgentHomeSnapshot:
if agent.status != AgentStatus.ACTIVE:
raise AgentHomeSnapshotUnavailableError(f"Agent {agent.id} is not active")
home_snapshot = session.scalar(
select(AgentHomeSnapshot).where(
AgentHomeSnapshot.id == home_snapshot_id,
AgentHomeSnapshot.tenant_id == agent.tenant_id,
AgentHomeSnapshot.agent_id == agent.id,
AgentHomeSnapshot.status == AgentWorkingResourceStatus.ACTIVE,
)
)
if home_snapshot is None:
raise AgentHomeSnapshotUnavailableError(f"Home Snapshot {home_snapshot_id} is unavailable for Agent {agent.id}")
return home_snapshot
__all__ = [
"AgentHomeSnapshotService",
"AgentHomeSnapshotUnavailableError",
"validate_home_snapshot_binding",
]
+166
View File
@@ -0,0 +1,166 @@
"""Workflow-only Agent ownership retirement after product transactions commit."""
from __future__ import annotations
import logging
from collections.abc import Iterable
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from models.agent import (
Agent,
AgentScope,
AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspaceBinding,
WorkflowAgentNodeBinding,
)
from models.enums import AppStatus
from models.model import App
from models.workflow import Workflow
from services.agent.home_snapshot_service import AgentHomeSnapshotService
from services.agent.workspace_service import AgentWorkspaceService
logger = logging.getLogger(__name__)
class WorkflowAgentRetirementService:
"""Archive workflow-only Agents once no effective binding owns them."""
@classmethod
def retire_unowned(
cls,
*,
tenant_id: str,
agent_ids: Iterable[str],
account_id: str | None,
) -> tuple[list[str], list[str]]:
"""Re-check ownership, archive orphans, and commit their resource retirement."""
candidates = tuple(sorted({agent_id for agent_id in agent_ids if agent_id}))
if not candidates:
return [], []
retired_bindings: list[str] = []
retired_snapshots: list[str] = []
try:
with session_factory.create_session() as session:
retired_agent_ids = cls.archive_unowned(
session=session,
tenant_id=tenant_id,
agent_ids=candidates,
account_id=account_id,
)
for agent_id in retired_agent_ids:
bindings = session.scalars(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.agent_id == agent_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
for binding in bindings:
binding_id = AgentWorkspaceService.retire_binding(
session=session,
tenant_id=tenant_id,
binding_id=binding.id,
)
if binding_id is not None:
retired_bindings.append(binding_id)
retired_snapshots.extend(
AgentHomeSnapshotService.retire_all_for_agent(
session=session,
tenant_id=tenant_id,
agent_id=agent_id,
)
)
session.commit()
except Exception:
logger.exception(
"Failed to retire unowned Workflow Agents",
extra={
"tenant_id": tenant_id,
"agent_ids": candidates,
},
)
return [], []
return retired_bindings, retired_snapshots
@classmethod
def archive_unowned(
cls,
*,
session: Session,
tenant_id: str,
agent_ids: Iterable[str],
account_id: str | None,
) -> list[str]:
"""Archive active orphans and return every orphan eligible for Home cleanup."""
candidates = tuple(sorted({agent_id for agent_id in agent_ids if agent_id}))
if not candidates:
return []
agents = session.scalars(
select(Agent).where(
Agent.tenant_id == tenant_id,
Agent.id.in_(candidates),
Agent.scope == AgentScope.WORKFLOW_ONLY,
Agent.status.in_((AgentStatus.ACTIVE, AgentStatus.ARCHIVED)),
)
).all()
effective_agent_ids = cls._effective_agent_ids(
session=session,
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents],
)
now = naive_utc_now()
cleanup_candidates: list[str] = []
for agent in agents:
if agent.id in effective_agent_ids:
continue
if agent.status == AgentStatus.ACTIVE:
agent.status = AgentStatus.ARCHIVED
agent.archived_by = account_id
agent.archived_at = now
agent.updated_by = account_id or agent.updated_by
agent.updated_at = now
cleanup_candidates.append(agent.id)
session.flush()
return cleanup_candidates
@staticmethod
def _effective_agent_ids(
*,
session: Session,
tenant_id: str,
agent_ids: list[str],
) -> set[str]:
if not agent_ids:
return set()
values = session.scalars(
select(WorkflowAgentNodeBinding.agent_id)
.join(
Workflow,
Workflow.id == WorkflowAgentNodeBinding.workflow_id,
)
.join(App, App.id == WorkflowAgentNodeBinding.app_id)
.where(
WorkflowAgentNodeBinding.tenant_id == tenant_id,
WorkflowAgentNodeBinding.agent_id.in_(agent_ids),
Workflow.tenant_id == tenant_id,
Workflow.app_id == WorkflowAgentNodeBinding.app_id,
Workflow.version == WorkflowAgentNodeBinding.workflow_version,
App.tenant_id == tenant_id,
App.status == AppStatus.NORMAL,
or_(
Workflow.version == Workflow.VERSION_DRAFT,
App.workflow_id == Workflow.id,
),
)
.distinct()
).all()
return {agent_id for agent_id in values if agent_id}
__all__ = ["WorkflowAgentRetirementService"]
+258 -171
View File
@@ -4,10 +4,8 @@ from typing import Any, TypedDict
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload
from constants.model_template import default_app_templates from constants.model_template import default_app_templates
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.app_invoke_entities import InvokeFrom
from libs.datetime_utils import naive_utc_now from libs.datetime_utils import naive_utc_now
from libs.helper import to_timestamp from libs.helper import to_timestamp
@@ -25,6 +23,9 @@ from models.agent import (
AgentScope, AgentScope,
AgentSource, AgentSource,
AgentStatus, AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
WorkflowAgentBindingType, WorkflowAgentBindingType,
WorkflowAgentNodeBinding, WorkflowAgentNodeBinding,
) )
@@ -36,15 +37,18 @@ from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import ( from services.agent.errors import (
AgentArchivedError, AgentArchivedError,
AgentBuildSandboxNotFoundError,
AgentNameConflictError, AgentNameConflictError,
AgentNotFoundError, AgentNotFoundError,
AgentVersionNotFoundError, AgentVersionNotFoundError,
) )
from services.agent.home_snapshot_service import AgentHomeSnapshotService
from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentWorkspaceService, WorkspaceOwnerScope
from services.app_service import AppService, CreateAppParams from services.app_service import AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload
from services.feature_service import FeatureService from services.feature_service import FeatureService
from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -304,6 +308,27 @@ class AgentRosterService:
account_id: str, account_id: str,
payload: RosterAgentCreatePayload, payload: RosterAgentCreatePayload,
source: AgentSource = AgentSource.ROSTER, source: AgentSource = AgentSource.ROSTER,
) -> Agent:
try:
agent = self._create_roster_agent_in_transaction(
tenant_id=tenant_id,
account_id=account_id,
payload=payload,
source=source,
)
self._session.commit()
return agent
except IntegrityError as exc:
self._session.rollback()
raise AgentNameConflictError() from exc
def _create_roster_agent_in_transaction(
self,
*,
tenant_id: str,
account_id: str,
payload: RosterAgentCreatePayload,
source: AgentSource,
) -> Agent: ) -> Agent:
ComposerConfigValidator.validate_agent_soul(payload.agent_soul) ComposerConfigValidator.validate_agent_soul(payload.agent_soul)
@@ -323,17 +348,19 @@ class AgentRosterService:
updated_by=account_id, updated_by=account_id,
) )
self._session.add(agent) self._session.add(agent)
try: self._session.flush()
self._session.flush()
except IntegrityError as exc:
self._session.rollback()
raise AgentNameConflictError() from exc
home_snapshot = AgentHomeSnapshotService.create_initial(
session=self._session,
tenant_id=tenant_id,
agent_id=agent.id,
)
version = AgentConfigSnapshot( version = AgentConfigSnapshot(
tenant_id=tenant_id, tenant_id=tenant_id,
agent_id=agent.id, agent_id=agent.id,
version=1, version=1,
config_snapshot=payload.agent_soul, config_snapshot=payload.agent_soul,
home_snapshot_id=home_snapshot.id,
version_note=payload.version_note, version_note=payload.version_note,
created_by=account_id, created_by=account_id,
) )
@@ -354,11 +381,6 @@ class AgentRosterService:
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
agent.active_config_is_published = True agent.active_config_is_published = True
try:
self._session.commit()
except IntegrityError as exc:
self._session.rollback()
raise AgentNameConflictError() from exc
return agent return agent
def create_backing_agent_for_app( def create_backing_agent_for_app(
@@ -405,17 +427,19 @@ class AgentRosterService:
updated_by=account_id, updated_by=account_id,
) )
self._session.add(agent) self._session.add(agent)
try: self._session.flush()
self._session.flush()
except IntegrityError as exc:
self._session.rollback()
raise AgentNameConflictError() from exc
home_snapshot = AgentHomeSnapshotService.create_initial(
session=self._session,
tenant_id=tenant_id,
agent_id=agent.id,
)
version = AgentConfigSnapshot( version = AgentConfigSnapshot(
tenant_id=tenant_id, tenant_id=tenant_id,
agent_id=agent.id, agent_id=agent.id,
version=1, version=1,
config_snapshot=soul, config_snapshot=soul,
home_snapshot_id=home_snapshot.id,
created_by=account_id, created_by=account_id,
) )
self._session.add(version) self._session.add(version)
@@ -590,16 +614,15 @@ class AgentRosterService:
self._session.flush() self._session.flush()
return conversation_id return conversation_id
def get_or_create_agent_app_debug_conversation_id( def get_or_create_build_conversation(
self, self,
*, *,
tenant_id: str, tenant_id: str,
agent_id: str, agent_id: str,
account_id: str, account_id: str,
draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD,
commit: bool = True, commit: bool = True,
) -> str: ) -> str:
"""Return the current editor's Build or Preview conversation for an Agent App.""" """Return the current editor's stable Build conversation."""
agent = self._session.scalar( agent = self._session.scalar(
select(Agent).where( select(Agent).where(
@@ -614,21 +637,20 @@ class AgentRosterService:
conversation_id = self._get_or_create_agent_app_debug_conversation( conversation_id = self._get_or_create_agent_app_debug_conversation(
agent=agent, agent=agent,
account_id=account_id, account_id=account_id,
draft_type=draft_type, draft_type=AgentConfigDraftType.DEBUG_BUILD,
) )
if commit: if commit:
self._session.commit() self._session.commit()
return conversation_id return conversation_id
def load_agent_app_debug_conversation_id( def get_current_preview_conversation(
self, self,
*, *,
tenant_id: str, tenant_id: str,
agent_id: str, agent_id: str,
account_id: str, account_id: str,
draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD,
) -> str | None: ) -> str | None:
"""Return the editor's existing scoped conversation without creating or repairing rows.""" """Return the editor's current Preview conversation without creating one."""
return self._session.scalar( return self._session.scalar(
select(Conversation.id) select(Conversation.id)
@@ -637,7 +659,7 @@ class AgentRosterService:
AgentDebugConversation.tenant_id == tenant_id, AgentDebugConversation.tenant_id == tenant_id,
AgentDebugConversation.agent_id == agent_id, AgentDebugConversation.agent_id == agent_id,
AgentDebugConversation.account_id == account_id, AgentDebugConversation.account_id == account_id,
AgentDebugConversation.draft_type == draft_type, AgentDebugConversation.draft_type == AgentConfigDraftType.DRAFT,
AgentDebugConversation.app_id == Conversation.app_id, AgentDebugConversation.app_id == Conversation.app_id,
Conversation.from_source == ConversationFromSource.CONSOLE, Conversation.from_source == ConversationFromSource.CONSOLE,
Conversation.from_account_id == account_id, Conversation.from_account_id == account_id,
@@ -657,25 +679,12 @@ class AgentRosterService:
or 0 or 0
) )
def refresh_agent_app_debug_conversation_id( def rotate_preview_conversation(self, *, tenant_id: str, agent_id: str, account_id: str) -> str:
self, """Rotate Preview and retire its exact Conversation-owned Binding.
*,
tenant_id: str,
agent_id: str,
account_id: str,
draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD,
) -> str:
"""Start a new scoped console conversation for the current Agent App editor.
If this account already has a mapping for the requested draft surface, the previous The mapping update and exact CONVERSATION Binding retirement commit in
conversation is abandoned after the replacement mapping is committed: any ACTIVE one transaction. Validation failures fail fast; collection is enqueued
conversation-owned Agent runtime sessions for that old conversation are sent through only after commit.
best-effort backend cleanup and then retired locally even when enqueueing fails. This
order prevents a failed database commit from retiring the still-current runtime session.
The other draft surface is left untouched.
A user and draft surface own one current mapping. If new-conversation requests overlap,
the last committed rotation becomes current and earlier response IDs cannot be continued.
""" """
agent = self._session.scalar( agent = self._session.scalar(
@@ -694,142 +703,194 @@ class AgentRosterService:
if not backing_app_id: if not backing_app_id:
raise AgentNotFoundError() raise AgentNotFoundError()
conversation_id = self._create_agent_app_debug_conversation( retired_binding_id: str | None = None
app_id=backing_app_id, try:
account_id=account_id, conversation_id = self._create_agent_app_debug_conversation(
) app_id=backing_app_id,
previous_conversation: tuple[str, str] | None = None account_id=account_id,
mapping = self._session.scalar(
select(AgentDebugConversation).where(
AgentDebugConversation.tenant_id == tenant_id,
AgentDebugConversation.agent_id == agent_id,
AgentDebugConversation.account_id == account_id,
AgentDebugConversation.draft_type == draft_type,
) )
) mapping = self._session.scalar(
if mapping is None: select(AgentDebugConversation).where(
self._session.add( AgentDebugConversation.tenant_id == tenant_id,
AgentDebugConversation( AgentDebugConversation.agent_id == agent_id,
tenant_id=tenant_id, AgentDebugConversation.account_id == account_id,
agent_id=agent_id, AgentDebugConversation.draft_type == AgentConfigDraftType.DRAFT,
app_id=backing_app_id,
account_id=account_id,
draft_type=draft_type,
conversation_id=conversation_id,
) )
) )
else: if mapping is None:
previous_app_id = mapping.app_id self._session.add(
previous_conversation_id = mapping.conversation_id AgentDebugConversation(
if previous_conversation_id: tenant_id=tenant_id,
previous_conversation = (previous_app_id or backing_app_id, previous_conversation_id) agent_id=agent_id,
mapping.app_id = backing_app_id app_id=backing_app_id,
mapping.conversation_id = conversation_id account_id=account_id,
self._session.flush() draft_type=AgentConfigDraftType.DRAFT,
self._session.commit() conversation_id=conversation_id,
)
if previous_conversation: )
previous_app_id, previous_conversation_id = previous_conversation else:
self._cleanup_debug_conversation_runtime_sessions( previous_app_id = mapping.app_id or backing_app_id
previous_conversation_id = mapping.conversation_id
if previous_conversation_id:
previous_conversation = self._session.scalar(
select(Conversation).where(
Conversation.id == previous_conversation_id,
Conversation.app_id == previous_app_id,
Conversation.from_source == ConversationFromSource.CONSOLE,
Conversation.from_account_id == account_id,
Conversation.is_deleted.is_(False),
)
)
if (
previous_conversation is not None
and previous_conversation.agent_workspace_binding_id is not None
):
binding_id = previous_conversation.agent_workspace_binding_id
binding = AgentWorkspaceService.get_active_binding(
session=self._session,
tenant_id=tenant_id,
binding_id=binding_id,
expected_owner_scope=WorkspaceOwnerScope(
tenant_id=tenant_id,
app_id=previous_app_id,
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id=previous_conversation.id,
),
)
if binding is None or binding.agent_id != agent_id:
raise AgentWorkspaceNotFoundError(
"Agent debug Conversation participant Binding is unavailable"
)
retired_binding_id = AgentWorkspaceService.retire_binding(
session=self._session,
tenant_id=tenant_id,
binding_id=binding_id,
)
if retired_binding_id is None:
raise AgentWorkspaceNotFoundError(
"Agent debug Conversation participant Binding is unavailable"
)
mapping.app_id = backing_app_id
mapping.conversation_id = conversation_id
self._session.flush()
self._session.commit()
except Exception:
self._session.rollback()
raise
if retired_binding_id is not None:
enqueue_agent_resource_collection(
tenant_id=tenant_id, tenant_id=tenant_id,
agent_id=agent_id, binding_ids=(retired_binding_id,),
account_id=account_id,
draft_type=draft_type,
app_id=previous_app_id,
conversation_id=previous_conversation_id,
) )
return conversation_id return conversation_id
def _cleanup_debug_conversation_runtime_sessions( def reset_build_conversation(self, *, tenant_id: str, agent_id: str, account_id: str) -> str:
self, """Reset Build and retire its exact DEBUG_BUILD Draft-owned Binding.
*,
tenant_id: str, The mapping update, exact BUILD_DRAFT Binding retirement, and Draft
agent_id: str, pointer clear commit in one transaction. Validation failures fail fast;
account_id: str, collection is enqueued only after commit.
draft_type: AgentConfigDraftType, """
app_id: str,
conversation_id: str, agent = self._session.scalar(
) -> None: select(Agent).where(
Agent.tenant_id == tenant_id,
Agent.id == agent_id,
Agent.status == AgentStatus.ACTIVE,
)
)
if agent is None:
raise AgentNotFoundError()
backing_app_id = self._ensure_workflow_agent_backing_app(
agent=agent,
account_id=agent.updated_by or agent.created_by,
)
if not backing_app_id:
raise AgentNotFoundError()
retired_binding_id: str | None = None
try: try:
session_store = AgentAppRuntimeSessionStore() conversation_id = self._create_agent_app_debug_conversation(
stored_sessions = session_store.list_active_sessions_for_conversation( app_id=backing_app_id,
tenant_id=tenant_id, account_id=account_id,
app_id=app_id,
conversation_id=conversation_id,
) )
except Exception: mapping = self._session.scalar(
logger.warning( select(AgentDebugConversation).where(
"Failed to load Agent App runtime sessions for debug conversation refresh: " AgentDebugConversation.tenant_id == tenant_id,
"tenant_id=%s app_id=%s conversation_id=%s", AgentDebugConversation.agent_id == agent_id,
tenant_id, AgentDebugConversation.account_id == account_id,
app_id, AgentDebugConversation.draft_type == AgentConfigDraftType.DEBUG_BUILD,
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}:{draft_type.value}:{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,
"draft_type": draft_type.value,
"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: build_draft = self._session.scalar(
session_store.mark_cleaned( select(AgentConfigDraft)
scope=stored_session.scope, .where(
backend_run_id=stored_session.backend_run_id, AgentConfigDraft.tenant_id == tenant_id,
) AgentConfigDraft.agent_id == agent_id,
except Exception: AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD,
logger.warning( AgentConfigDraft.account_id == account_id,
"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", .order_by(AgentConfigDraft.updated_at.desc())
stored_session.scope.tenant_id, .limit(1)
stored_session.scope.app_id, )
stored_session.scope.conversation_id, if build_draft is not None and build_draft.agent_workspace_binding_id is not None:
stored_session.scope.agent_id, binding_id = build_draft.agent_workspace_binding_id
stored_session.backend_run_id, binding = AgentWorkspaceService.get_active_binding(
exc_info=True, session=self._session,
) tenant_id=tenant_id,
binding_id=binding_id,
expected_owner_scope=WorkspaceOwnerScope(
tenant_id=tenant_id,
app_id=backing_app_id,
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
owner_id=build_draft.id,
),
)
if binding is None or binding.agent_id != agent_id:
raise AgentBuildSandboxNotFoundError()
retired_binding_id = AgentWorkspaceService.retire_binding(
session=self._session,
tenant_id=tenant_id,
binding_id=binding_id,
)
if retired_binding_id is None:
raise AgentBuildSandboxNotFoundError()
build_draft.agent_workspace_binding_id = None
def load_or_create_agent_app_debug_conversation_ids_by_agent_id( if mapping is None:
self._session.add(
AgentDebugConversation(
tenant_id=tenant_id,
agent_id=agent_id,
app_id=backing_app_id,
account_id=account_id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
conversation_id=conversation_id,
)
)
else:
mapping.app_id = backing_app_id
mapping.conversation_id = conversation_id
self._session.flush()
self._session.commit()
except Exception:
self._session.rollback()
raise
if retired_binding_id is not None:
enqueue_agent_resource_collection(
tenant_id=tenant_id,
binding_ids=(retired_binding_id,),
)
return conversation_id
def load_or_create_build_conversation_ids_by_agent_id(
self, self,
*, *,
tenant_id: str, tenant_id: str,
agents: list[Agent], agents: list[Agent],
account_id: str, account_id: str,
draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD,
) -> dict[str, str]: ) -> dict[str, str]:
"""Return per-account scoped conversations for a page of Agent Apps.""" """Return per-account Build conversations for a page of Agent Apps."""
conversation_ids_by_agent_id: dict[str, str] = {} conversation_ids_by_agent_id: dict[str, str] = {}
changed = False changed = False
@@ -839,7 +900,7 @@ class AgentRosterService:
conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation( conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation(
agent=agent, agent=agent,
account_id=account_id, account_id=account_id,
draft_type=draft_type, draft_type=AgentConfigDraftType.DEBUG_BUILD,
) )
changed = True changed = True
if changed: if changed:
@@ -1057,7 +1118,6 @@ class AgentRosterService:
account_id=account.id, account_id=account.id,
) )
self._session.commit() self._session.commit()
if FeatureService.get_system_features().webapp_auth.enabled: if FeatureService.get_system_features().webapp_auth.enabled:
try: try:
original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(source_app.id) original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(source_app.id)
@@ -1211,13 +1271,38 @@ class AgentRosterService:
def archive_roster_agent(self, *, tenant_id: str, agent_id: str, account_id: str) -> None: def archive_roster_agent(self, *, tenant_id: str, agent_id: str, account_id: str) -> None:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
if agent.status == AgentStatus.ARCHIVED: retired_binding_ids: list[str] = []
return if agent.status != AgentStatus.ARCHIVED:
agent.status = AgentStatus.ARCHIVED agent.status = AgentStatus.ARCHIVED
agent.archived_by = account_id agent.archived_by = account_id
agent.archived_at = naive_utc_now() agent.archived_at = naive_utc_now()
agent.updated_by = account_id agent.updated_by = account_id
bindings = self._session.scalars(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.agent_id == agent_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
for binding in bindings:
retired_id = AgentWorkspaceService.retire_binding(
session=self._session,
tenant_id=tenant_id,
binding_id=binding.id,
)
if retired_id is not None:
retired_binding_ids.append(retired_id)
retired_snapshot_ids = AgentHomeSnapshotService.retire_all_for_agent(
session=self._session,
tenant_id=tenant_id,
agent_id=agent_id,
)
self._session.commit() self._session.commit()
enqueue_agent_resource_collection(
tenant_id=tenant_id,
binding_ids=retired_binding_ids,
home_snapshot_ids=retired_snapshot_ids,
)
@staticmethod @staticmethod
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]: def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
@@ -1382,9 +1467,11 @@ class AgentRosterService:
account_id=None, account_id=None,
draft_owner_key="", draft_owner_key="",
created_by=account_id, created_by=account_id,
home_snapshot_id=version.home_snapshot_id,
) )
self._session.add(draft) self._session.add(draft)
draft.base_snapshot_id = version.id draft.base_snapshot_id = version.id
draft.home_snapshot_id = version.home_snapshot_id
draft.config_snapshot = AgentSoulConfig.model_validate(version.config_snapshot_dict) draft.config_snapshot = AgentSoulConfig.model_validate(version.config_snapshot_dict)
draft.updated_by = account_id draft.updated_by = account_id
agent.active_config_is_published = version.id == agent.active_config_snapshot_id agent.active_config_is_published = version.id == agent.active_config_snapshot_id
+57 -8
View File
@@ -24,6 +24,7 @@ from models.agent_config_entities import (
WorkflowNodeJobConfig, WorkflowNodeJobConfig,
WorkflowPreviousNodeOutputRef, WorkflowPreviousNodeOutputRef,
) )
from models.model import App
from models.workflow import Workflow from models.workflow import Workflow
from services.agent.composer_validator import ComposerConfigValidator from services.agent.composer_validator import ComposerConfigValidator
from services.agent.prompt_mentions import ( from services.agent.prompt_mentions import (
@@ -224,7 +225,7 @@ class WorkflowAgentPublishService:
session: Session, session: Session,
draft_workflow: Workflow, draft_workflow: Workflow,
account_id: str, account_id: str,
) -> None: ) -> set[str]:
agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict)) agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict))
existing_bindings = list( existing_bindings = list(
session.scalars( session.scalars(
@@ -237,9 +238,12 @@ class WorkflowAgentPublishService:
).all() ).all()
) )
existing_by_node_id = {binding.node_id: binding for binding in existing_bindings} existing_by_node_id = {binding.node_id: binding for binding in existing_bindings}
retirement_candidates: set[str] = set()
for binding in existing_bindings: for binding in existing_bindings:
if binding.node_id not in agent_nodes: if binding.node_id not in agent_nodes:
if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and binding.agent_id:
retirement_candidates.add(binding.agent_id)
session.delete(binding) session.delete(binding)
for node_id, node_data in agent_nodes.items(): for node_id, node_data in agent_nodes.items():
@@ -252,16 +256,34 @@ class WorkflowAgentPublishService:
not binding_payload.get("agent_id") or not binding_payload.get("current_snapshot_id") not binding_payload.get("agent_id") or not binding_payload.get("current_snapshot_id")
): ):
continue continue
existing_binding = existing_by_node_id.get(node_id)
replaced_inline_agent_id = (
existing_binding.agent_id
if existing_binding is not None
and existing_binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
and existing_binding.agent_id
else None
)
cls._sync_agent_binding_for_node( cls._sync_agent_binding_for_node(
session=session, session=session,
draft_workflow=draft_workflow, draft_workflow=draft_workflow,
node_id=node_id, node_id=node_id,
node_data=node_data, node_data=node_data,
node_binding=binding_payload, node_binding=binding_payload,
existing_binding=existing_by_node_id.get(node_id), existing_binding=existing_binding,
account_id=account_id, account_id=account_id,
) )
if (
replaced_inline_agent_id
and existing_binding is not None
and (
existing_binding.binding_type != WorkflowAgentBindingType.INLINE_AGENT
or existing_binding.agent_id != replaced_inline_agent_id
)
):
retirement_candidates.add(replaced_inline_agent_id)
session.flush() session.flush()
return retirement_candidates
@classmethod @classmethod
def sync_roster_agent_bindings_for_draft( def sync_roster_agent_bindings_for_draft(
@@ -270,8 +292,8 @@ class WorkflowAgentPublishService:
session: Session, session: Session,
draft_workflow: Workflow, draft_workflow: Workflow,
account_id: str, account_id: str,
) -> None: ) -> set[str]:
cls.sync_agent_bindings_for_draft( return cls.sync_agent_bindings_for_draft(
session=session, session=session,
draft_workflow=draft_workflow, draft_workflow=draft_workflow,
account_id=account_id, account_id=account_id,
@@ -561,12 +583,32 @@ class WorkflowAgentPublishService:
session: Session, session: Session,
draft_workflow: Workflow, draft_workflow: Workflow,
published_workflow: Workflow, published_workflow: Workflow,
) -> None: ) -> set[str]:
current_workflow_id = session.scalar(
select(App.workflow_id).where(
App.tenant_id == draft_workflow.tenant_id,
App.id == draft_workflow.app_id,
)
)
retirement_candidates: set[str] = set()
if current_workflow_id:
retirement_candidates = {
agent_id
for agent_id in session.scalars(
select(WorkflowAgentNodeBinding.agent_id).where(
WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id,
WorkflowAgentNodeBinding.app_id == draft_workflow.app_id,
WorkflowAgentNodeBinding.workflow_id == current_workflow_id,
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.INLINE_AGENT,
)
).all()
if agent_id
}
node_ids = { node_ids = {
node_id for node_id, _node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict) node_id for node_id, _node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict)
} }
if not node_ids: if not node_ids:
return return retirement_candidates
bindings = session.scalars( bindings = session.scalars(
select(WorkflowAgentNodeBinding).where( select(WorkflowAgentNodeBinding).where(
@@ -578,7 +620,7 @@ class WorkflowAgentPublishService:
) )
).all() ).all()
if not bindings: if not bindings:
return return retirement_candidates
agents_by_id = { agents_by_id = {
agent.id: agent agent.id: agent
@@ -611,6 +653,7 @@ class WorkflowAgentPublishService:
updated_by=binding.updated_by, updated_by=binding.updated_by,
) )
session.add(copied) session.add(copied)
return retirement_candidates
@classmethod @classmethod
def restore_agent_node_bindings_to_draft( def restore_agent_node_bindings_to_draft(
@@ -620,7 +663,7 @@ class WorkflowAgentPublishService:
source_workflow: Workflow, source_workflow: Workflow,
draft_workflow: Workflow, draft_workflow: Workflow,
account_id: str, account_id: str,
) -> None: ) -> set[str]:
"""Replace draft bindings with the frozen bindings of a published workflow.""" """Replace draft bindings with the frozen bindings of a published workflow."""
existing = session.scalars( existing = session.scalars(
@@ -631,6 +674,11 @@ class WorkflowAgentPublishService:
WorkflowAgentNodeBinding.workflow_version == cls._DRAFT_WORKFLOW_VERSION, WorkflowAgentNodeBinding.workflow_version == cls._DRAFT_WORKFLOW_VERSION,
) )
).all() ).all()
retirement_candidates = {
binding.agent_id
for binding in existing
if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and binding.agent_id
}
for binding in existing: for binding in existing:
session.delete(binding) session.delete(binding)
@@ -681,3 +729,4 @@ class WorkflowAgentPublishService:
) )
) )
session.flush() session.flush()
return retirement_candidates
+469
View File
@@ -0,0 +1,469 @@
"""Own Workspace and AgentWorkspaceBinding product lifecycle.
Dify API is the lifecycle ledger. Dify Agent only executes physical create,
acquire, and destroy operations selected by this service. Retire methods only
mutate the caller's transaction; collection performs network I/O after commit
and deletes ledger rows only after idempotent physical cleanup succeeds.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from dify_agent.client import Client
from dify_agent.protocol import CreateExecutionBindingRequest, DestroyExecutionBindingRequest
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from libs.uuid_utils import uuidv7
from models.agent import (
AgentConfigVersionKind,
AgentHomeSnapshot,
AgentWorkingResourceStatus,
AgentWorkspace,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
)
logger = logging.getLogger(__name__)
class AgentWorkspaceError(RuntimeError):
pass
class AgentWorkspaceNotFoundError(AgentWorkspaceError):
pass
class AgentWorkspaceBindingGenerationMismatchError(AgentWorkspaceError):
pass
@dataclass(frozen=True, slots=True)
class WorkspaceOwnerScope:
tenant_id: str
app_id: str
owner_type: AgentWorkspaceOwnerType
owner_id: str
owner_scope_key: str = "root"
class AgentWorkspaceService:
"""Allocate and manage working-environment resources.
A Binding ID is the participant identity. Product callers persist that ID
and use :meth:`get_active_binding`; Agent and Workspace attributes are not
participant lookup keys.
"""
@classmethod
def resolve_active_workspace(cls, *, session: Session, scope: WorkspaceOwnerScope) -> AgentWorkspace | None:
return session.scalar(
select(AgentWorkspace).where(
AgentWorkspace.tenant_id == scope.tenant_id,
AgentWorkspace.app_id == scope.app_id,
AgentWorkspace.owner_type == scope.owner_type,
AgentWorkspace.owner_id == scope.owner_id,
AgentWorkspace.owner_scope_key == scope.owner_scope_key,
AgentWorkspace.status == AgentWorkingResourceStatus.ACTIVE,
)
)
@classmethod
def get_active_binding(
cls,
*,
session: Session,
tenant_id: str,
binding_id: str,
expected_owner_scope: WorkspaceOwnerScope,
) -> AgentWorkspaceBinding | None:
return session.scalar(
select(AgentWorkspaceBinding)
.join(
AgentWorkspace,
(AgentWorkspace.tenant_id == AgentWorkspaceBinding.tenant_id)
& (AgentWorkspace.id == AgentWorkspaceBinding.workspace_id),
)
.where(
AgentWorkspaceBinding.id == binding_id,
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
AgentWorkspace.tenant_id == expected_owner_scope.tenant_id,
AgentWorkspace.app_id == expected_owner_scope.app_id,
AgentWorkspace.owner_type == expected_owner_scope.owner_type,
AgentWorkspace.owner_id == expected_owner_scope.owner_id,
AgentWorkspace.owner_scope_key == expected_owner_scope.owner_scope_key,
AgentWorkspace.status == AgentWorkingResourceStatus.ACTIVE,
)
)
@classmethod
def create_binding(
cls,
*,
session: Session,
scope: WorkspaceOwnerScope,
agent_id: str,
base_home_snapshot_id: str,
agent_config_version_id: str,
agent_config_version_kind: AgentConfigVersionKind,
) -> AgentWorkspaceBinding:
"""Allocate one new participant in the caller-owned transaction.
After backend creation returns successfully, any later Python, flush,
or commit failure may leave an orphan. Dify API does not perform
cross-system compensation; a future global reconciler is responsible
for those orphans. Backend-local cleanup applies only when creation
fails before the backend returns success.
"""
home_snapshot = session.scalar(
select(AgentHomeSnapshot).where(
AgentHomeSnapshot.id == base_home_snapshot_id,
AgentHomeSnapshot.tenant_id == scope.tenant_id,
AgentHomeSnapshot.agent_id == agent_id,
AgentHomeSnapshot.status == AgentWorkingResourceStatus.ACTIVE,
)
)
if home_snapshot is None:
raise AgentWorkspaceNotFoundError("base Home Snapshot is unavailable")
workspace = cls.resolve_active_workspace(session=session, scope=scope)
workspace_id = workspace.id if workspace is not None else str(uuidv7())
binding_id = str(uuidv7())
with cls._client() as client:
allocation = client.create_execution_binding_sync(
CreateExecutionBindingRequest(
tenant_id=scope.tenant_id,
agent_id=agent_id,
binding_id=binding_id,
workspace_id=workspace_id,
existing_workspace_ref=workspace.backend_workspace_ref if workspace is not None else None,
home_snapshot_ref=home_snapshot.snapshot_ref,
)
)
if workspace is not None and allocation.workspace_ref != workspace.backend_workspace_ref:
raise AgentWorkspaceError("backend changed the existing Workspace ref")
if workspace is None:
workspace = AgentWorkspace(
id=workspace_id,
tenant_id=scope.tenant_id,
app_id=scope.app_id,
owner_type=scope.owner_type,
owner_id=scope.owner_id,
owner_scope_key=scope.owner_scope_key,
backend_workspace_ref=allocation.workspace_ref,
status=AgentWorkingResourceStatus.ACTIVE,
active_guard=1,
)
session.add(workspace)
binding = AgentWorkspaceBinding(
id=binding_id,
tenant_id=scope.tenant_id,
app_id=scope.app_id,
workspace_id=workspace_id,
agent_id=agent_id,
base_home_snapshot_id=base_home_snapshot_id,
agent_config_version_id=agent_config_version_id,
agent_config_version_kind=agent_config_version_kind,
backend_binding_ref=allocation.binding_ref,
status=AgentWorkingResourceStatus.ACTIVE,
)
session.add(binding)
return binding
@classmethod
def save_binding_session_snapshot(
cls,
*,
tenant_id: str,
binding_id: str,
session_snapshot: str,
pending_form_id: str | None = None,
pending_tool_call_id: str | None = None,
) -> None:
with session_factory.create_session() as session:
binding = session.scalar(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.id == binding_id,
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
)
)
if binding is None:
raise AgentWorkspaceNotFoundError("ACTIVE Binding is unavailable")
binding.session_snapshot = session_snapshot
binding.pending_form_id = pending_form_id
binding.pending_tool_call_id = pending_tool_call_id
session.commit()
@classmethod
def retire_binding(cls, *, session: Session, tenant_id: str, binding_id: str) -> str | None:
binding = session.scalar(
select(AgentWorkspaceBinding)
.where(
AgentWorkspaceBinding.id == binding_id,
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
)
.with_for_update()
)
if binding is None:
return None
workspace = session.scalar(
select(AgentWorkspace)
.where(
AgentWorkspace.id == binding.workspace_id,
AgentWorkspace.tenant_id == tenant_id,
AgentWorkspace.status == AgentWorkingResourceStatus.ACTIVE,
)
.with_for_update()
)
now = naive_utc_now()
binding.status = AgentWorkingResourceStatus.RETIRED
binding.retired_at = now
if workspace is not None:
other_binding = session.scalar(
select(AgentWorkspaceBinding.id).where(
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.workspace_id == workspace.id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
AgentWorkspaceBinding.id != binding.id,
)
)
if other_binding is None:
workspace.status = AgentWorkingResourceStatus.RETIRED
workspace.active_guard = None
workspace.retired_at = now
return binding.id
@classmethod
def retire_workspace(cls, *, session: Session, tenant_id: str, workspace_id: str) -> str | None:
workspace = session.scalar(
select(AgentWorkspace)
.where(
AgentWorkspace.id == workspace_id,
AgentWorkspace.tenant_id == tenant_id,
AgentWorkspace.status == AgentWorkingResourceStatus.ACTIVE,
)
.with_for_update()
)
if workspace is None:
return None
now = naive_utc_now()
workspace.status = AgentWorkingResourceStatus.RETIRED
workspace.active_guard = None
workspace.retired_at = now
bindings = session.scalars(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.workspace_id == workspace.id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
for binding in bindings:
binding.status = AgentWorkingResourceStatus.RETIRED
binding.retired_at = now
return workspace.id
@classmethod
def retire_all_for_app(cls, *, session: Session, tenant_id: str, app_id: str) -> list[str]:
"""Retire all ACTIVE Workspaces owned by an App in the caller's transaction."""
workspaces = session.scalars(
select(AgentWorkspace).where(
AgentWorkspace.tenant_id == tenant_id,
AgentWorkspace.app_id == app_id,
AgentWorkspace.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
retired: list[str] = []
for workspace in workspaces:
workspace_id = cls.retire_workspace(
session=session,
tenant_id=tenant_id,
workspace_id=workspace.id,
)
if workspace_id is not None:
retired.append(workspace_id)
return retired
@classmethod
def collect_retired_binding(cls, *, tenant_id: str, binding_id: str) -> None:
try:
cls._collect_retired_binding(tenant_id=tenant_id, binding_id=binding_id)
except Exception:
logger.exception(
"Failed to collect retired Agent Workspace Binding",
extra={"tenant_id": tenant_id, "binding_id": binding_id},
)
@classmethod
def _collect_retired_binding(cls, *, tenant_id: str, binding_id: str) -> None:
with session_factory.create_session() as session:
binding = session.scalar(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.id == binding_id,
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.RETIRED,
)
)
if binding is None:
return
backend_binding_ref = binding.backend_binding_ref
workspace = session.scalar(
select(AgentWorkspace).where(
AgentWorkspace.id == binding.workspace_id,
AgentWorkspace.tenant_id == tenant_id,
)
)
if workspace is not None and workspace.status == AgentWorkingResourceStatus.RETIRED:
workspace_id = workspace.id
else:
workspace_id = None
if workspace_id is not None:
cls.collect_retired_workspace(tenant_id=tenant_id, workspace_id=workspace_id)
return
try:
with cls._client() as client:
client.destroy_execution_binding_sync(
DestroyExecutionBindingRequest(
binding_ref=backend_binding_ref,
destroy_workspace=False,
)
)
except Exception:
logger.exception(
"Failed to collect retired Agent Workspace Binding",
extra={"tenant_id": tenant_id, "binding_id": binding_id},
)
return
with session_factory.create_session() as session:
binding = session.scalar(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.id == binding_id,
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.RETIRED,
)
)
if binding is not None:
session.delete(binding)
session.commit()
@classmethod
def collect_retired_workspace(cls, *, tenant_id: str, workspace_id: str) -> None:
try:
cls._collect_retired_workspace(tenant_id=tenant_id, workspace_id=workspace_id)
except Exception:
logger.exception(
"Failed to collect retired Agent Workspace",
extra={"tenant_id": tenant_id, "workspace_id": workspace_id},
)
@classmethod
def _collect_retired_workspace(cls, *, tenant_id: str, workspace_id: str) -> None:
with session_factory.create_session() as session:
workspace = session.scalar(
select(AgentWorkspace).where(
AgentWorkspace.id == workspace_id,
AgentWorkspace.tenant_id == tenant_id,
AgentWorkspace.status == AgentWorkingResourceStatus.RETIRED,
)
)
if workspace is None:
return
bindings = session.scalars(
select(AgentWorkspaceBinding)
.where(
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.workspace_id == workspace_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.RETIRED,
)
.order_by(AgentWorkspaceBinding.created_at)
).all()
if not bindings:
logger.error(
"RETIRED Workspace has no Binding available for physical collection",
extra={"tenant_id": tenant_id, "workspace_id": workspace_id},
)
return
anchor = bindings[0]
remaining_ids = [binding.id for binding in bindings[1:]]
workspace_ref = workspace.backend_workspace_ref
binding_ref = anchor.backend_binding_ref
anchor_id = anchor.id
try:
with cls._client() as client:
client.destroy_execution_binding_sync(
DestroyExecutionBindingRequest(
binding_ref=binding_ref,
workspace_ref=workspace_ref,
destroy_workspace=True,
)
)
except Exception:
logger.exception(
"Failed to collect retired Agent Workspace",
extra={"tenant_id": tenant_id, "workspace_id": workspace_id, "binding_id": anchor_id},
)
return
with session_factory.create_session() as session:
stored_workspace = session.scalar(
select(AgentWorkspace).where(
AgentWorkspace.id == workspace_id,
AgentWorkspace.tenant_id == tenant_id,
AgentWorkspace.status == AgentWorkingResourceStatus.RETIRED,
)
)
stored_anchor = session.scalar(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.id == anchor_id,
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.RETIRED,
)
)
if stored_workspace is not None:
session.delete(stored_workspace)
if stored_anchor is not None:
session.delete(stored_anchor)
session.commit()
for remaining_id in remaining_ids:
cls.collect_retired_binding(tenant_id=tenant_id, binding_id=remaining_id)
@staticmethod
def validate_binding_generation(
binding: AgentWorkspaceBinding,
*,
base_home_snapshot_id: str,
agent_config_version_id: str,
agent_config_version_kind: AgentConfigVersionKind,
) -> None:
if (
binding.base_home_snapshot_id != base_home_snapshot_id
or binding.agent_config_version_id != agent_config_version_id
or binding.agent_config_version_kind != agent_config_version_kind
):
raise AgentWorkspaceBindingGenerationMismatchError(
"ACTIVE Binding belongs to a different Agent config/Home generation"
)
@staticmethod
def _client() -> Client:
base_url = dify_config.AGENT_BACKEND_BASE_URL
if not base_url:
raise AgentWorkspaceError("Dify Agent backend is required for Workspace operations")
return Client(base_url=base_url)
__all__ = [
"AgentWorkspaceBindingGenerationMismatchError",
"AgentWorkspaceError",
"AgentWorkspaceNotFoundError",
"AgentWorkspaceService",
"WorkspaceOwnerScope",
]
+276 -176
View File
@@ -1,39 +1,40 @@
"""Resolve and proxy sandbox file access for Agent App and workflow Agent sessions. """Resolve product locators to ACTIVE Workspace Bindings and proxy file access."""
These services keep product-facing locators (conversation, workflow run, node)
on the API boundary and translate them into the agent backend's
``SandboxLocator`` using persisted non-sensitive runtime layer specs plus the
saved Agenton session snapshot. Upload responses stay console-facing here: the
agent backend still returns a canonical ToolFile mapping, while this API layer
re-resolves that mapping into a signed browser download URL.
"""
from __future__ import annotations from __future__ import annotations
import urllib.parse import urllib.parse
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any, Literal, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.client import Client from dify_agent.client import Client
from dify_agent.protocol import RuntimeLayerSpec, SandboxLocator, build_sandbox_locator_from_layer_specs from dify_agent.layers.execution_context import (
from pydantic import BaseModel, TypeAdapter DifyExecutionContextAgentConfigVersionKind,
DifyExecutionContextLayerConfig,
)
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse, WorkspaceUploadRequest
from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from configs import dify_config from configs import dify_config
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
from core.app.file_access import DatabaseFileAccessController from core.app.file_access import DatabaseFileAccessController
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
from core.db.session_factory import session_factory
from factories import file_factory from factories import file_factory
from models.agent import AgentRuntimeSessionOwnerType, WorkflowAgentRuntimeSession, WorkflowAgentRuntimeSessionStatus from models.agent import (
Agent,
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec]) AgentConfigDraft,
AgentConfigDraftType,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
)
from models.model import App, Conversation
from models.workflow import WorkflowNodeExecutionModel
from services.agent.roster_service import AgentRosterService
from services.agent.workspace_service import AgentWorkspaceService, WorkspaceOwnerScope
class AgentSandboxInspectorError(Exception): class AgentSandboxInspectorError(Exception):
"""A sandbox inspection failure mapped to an HTTP status by the controller."""
code: str code: str
message: str message: str
status_code: int status_code: int
@@ -46,82 +47,200 @@ class AgentSandboxInspectorError(Exception):
class AgentSandboxInfo(BaseModel): class AgentSandboxInfo(BaseModel):
"""Basic Agent App sandbox metadata returned after a successful availability probe."""
session_id: str
workspace_cwd: str workspace_cwd: str
class AgentSandboxUploadDownload(BaseModel): class AgentSandboxUploadDownload(BaseModel):
"""Signed browser download URL for one sandbox upload result."""
url: str url: str
class AgentAppSandboxService: class AgentAppSandboxService:
"""Inspect and proxy file access for an Agent App conversation sandbox.""" def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
def __init__(
self,
*,
session_store: AgentAppRuntimeSessionStore | None = None,
client_factory: Callable[[], Client] | None = None,
) -> None:
self._session_store = session_store or AgentAppRuntimeSessionStore()
self._client_factory = client_factory or _default_client_factory self._client_factory = client_factory or _default_client_factory
def get_info(self, *, tenant_id: str, app_id: str, conversation_id: str) -> AgentSandboxInfo: def get_info(
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) self,
session_id, workspace_cwd = _extract_shell_workspace_or_raise( *,
snapshot=locator.session_snapshot, tenant_id: str,
not_found_message="this conversation's agent has no sandbox workspace", app_id: str,
) agent_id: str,
caller_type: Literal["conversation", "build_draft"],
return AgentSandboxInfo( caller_id: str,
session_id=session_id, account_id: str,
workspace_cwd=workspace_cwd, ) -> AgentSandboxInfo:
) self._resolve_binding(
def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().list_sandbox_files_sync(locator, path)
def read_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().read_sandbox_file_sync(locator, path)
def upload_file(
self, *, tenant_id: str, app_id: str, conversation_id: str, path: str
) -> AgentSandboxUploadDownload:
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
uploaded = self._client_factory().upload_sandbox_file_sync(locator, path)
return _upload_download_response(
tenant_id=tenant_id,
file_mapping=uploaded.file.model_dump(mode="python"),
)
def _resolve_locator(self, *, tenant_id: str, app_id: str, conversation_id: str) -> SandboxLocator:
stored = self._session_store.load_active_session_for_conversation(
tenant_id=tenant_id, tenant_id=tenant_id,
app_id=app_id, app_id=app_id,
conversation_id=conversation_id, agent_id=agent_id,
caller_type=caller_type,
caller_id=caller_id,
account_id=account_id,
) )
if stored is None: return AgentSandboxInfo(workspace_cwd=".")
raise AgentSandboxInspectorError(
"no_active_session", def list_files(
"this conversation has no active sandbox session yet", self,
status_code=404, *,
tenant_id: str,
app_id: str,
agent_id: str,
caller_type: Literal["conversation", "build_draft"],
caller_id: str,
account_id: str,
path: str,
) -> WorkspaceListResponse:
binding = self._resolve_binding(
tenant_id=tenant_id,
app_id=app_id,
agent_id=agent_id,
caller_type=caller_type,
caller_id=caller_id,
account_id=account_id,
)
with self._client_factory() as client:
return client.list_workspace_files_sync(binding.backend_binding_ref, path)
def read_file(
self,
*,
tenant_id: str,
app_id: str,
agent_id: str,
caller_type: Literal["conversation", "build_draft"],
caller_id: str,
account_id: str,
path: str,
) -> WorkspaceReadResponse:
binding = self._resolve_binding(
tenant_id=tenant_id,
app_id=app_id,
agent_id=agent_id,
caller_type=caller_type,
caller_id=caller_id,
account_id=account_id,
)
with self._client_factory() as client:
return client.read_workspace_file_sync(binding.backend_binding_ref, path)
def upload_file(
self,
*,
tenant_id: str,
app_id: str,
agent_id: str,
caller_type: Literal["conversation", "build_draft"],
caller_id: str,
account_id: str,
path: str,
) -> AgentSandboxUploadDownload:
binding = self._resolve_binding(
tenant_id=tenant_id,
app_id=app_id,
agent_id=agent_id,
caller_type=caller_type,
caller_id=caller_id,
account_id=account_id,
)
with self._client_factory() as client:
uploaded = client.upload_workspace_file_sync(
WorkspaceUploadRequest(
backend_binding_ref=binding.backend_binding_ref,
path=path,
execution_context=DifyExecutionContextLayerConfig(
tenant_id=tenant_id,
app_id=app_id,
conversation_id=caller_id if caller_type == "conversation" else None,
agent_id=agent_id,
agent_config_version_id=binding.agent_config_version_id,
agent_config_version_kind=cast(
DifyExecutionContextAgentConfigVersionKind,
binding.agent_config_version_kind.value,
),
agent_mode="agent_app",
invoke_from="debugger",
),
)
) )
return _build_locator_or_raise( return _upload_download_response(tenant_id=tenant_id, file_mapping=uploaded.file.model_dump(mode="python"))
snapshot=stored.session_snapshot,
runtime_layer_specs=stored.runtime_layer_specs, @staticmethod
not_found_message="this conversation's agent has no sandbox workspace", def _resolve_binding(
) *,
tenant_id: str,
app_id: str,
agent_id: str,
caller_type: Literal["conversation", "build_draft"],
caller_id: str,
account_id: str,
) -> AgentWorkspaceBinding:
with session_factory.create_session() as session:
caller: AgentConfigDraft | Conversation | None
if caller_type == "build_draft":
agent = session.scalar(
select(Agent).where(
Agent.id == agent_id,
Agent.tenant_id == tenant_id,
)
)
if agent is None or AgentRosterService.runtime_backing_app_id(agent) != app_id:
caller = None
else:
caller = session.scalar(
select(AgentConfigDraft).where(
AgentConfigDraft.id == caller_id,
AgentConfigDraft.tenant_id == tenant_id,
AgentConfigDraft.agent_id == agent_id,
AgentConfigDraft.account_id == account_id,
AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD,
)
)
owner_scope = WorkspaceOwnerScope(
tenant_id=tenant_id,
app_id=app_id,
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
owner_id=caller_id,
)
else:
caller = session.scalar(
select(Conversation)
.join(App, App.id == Conversation.app_id)
.where(
App.tenant_id == tenant_id,
Conversation.app_id == app_id,
Conversation.id == caller_id,
Conversation.from_account_id == account_id,
Conversation.is_deleted.is_(False),
)
)
owner_scope = WorkspaceOwnerScope(
tenant_id=tenant_id,
app_id=app_id,
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id=caller_id,
)
if caller is None or caller.agent_workspace_binding_id is None:
raise AgentSandboxInspectorError(
"no_active_binding",
"this caller has no active Agent Workspace Binding",
status_code=404,
)
binding = AgentWorkspaceService.get_active_binding(
session=session,
tenant_id=tenant_id,
binding_id=caller.agent_workspace_binding_id,
expected_owner_scope=owner_scope,
)
if binding is None or binding.agent_id != agent_id:
raise AgentSandboxInspectorError(
"no_active_binding",
"this caller has no active Agent Workspace Binding",
status_code=404,
)
session.expunge(binding)
return binding
class WorkflowAgentSandboxService: class WorkflowAgentSandboxService:
"""List/read/upload files in a workflow Agent node sandbox."""
def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None: def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
self._client_factory = client_factory or _default_client_factory self._client_factory = client_factory or _default_client_factory
@@ -132,11 +251,11 @@ class WorkflowAgentSandboxService:
app_id: str, app_id: str,
workflow_run_id: str, workflow_run_id: str,
node_id: str, node_id: str,
node_execution_id: str | None, node_execution_id: str,
path: str, path: str,
session: Session, session: Session,
): ) -> WorkspaceListResponse:
locator = self._resolve_locator( binding = self._resolve_binding(
tenant_id=tenant_id, tenant_id=tenant_id,
app_id=app_id, app_id=app_id,
workflow_run_id=workflow_run_id, workflow_run_id=workflow_run_id,
@@ -144,7 +263,8 @@ class WorkflowAgentSandboxService:
node_execution_id=node_execution_id, node_execution_id=node_execution_id,
session=session, session=session,
) )
return self._client_factory().list_sandbox_files_sync(locator, path) with self._client_factory() as client:
return client.list_workspace_files_sync(binding.backend_binding_ref, path)
def read_file( def read_file(
self, self,
@@ -153,11 +273,11 @@ class WorkflowAgentSandboxService:
app_id: str, app_id: str,
workflow_run_id: str, workflow_run_id: str,
node_id: str, node_id: str,
node_execution_id: str | None, node_execution_id: str,
path: str, path: str,
session: Session, session: Session,
): ) -> WorkspaceReadResponse:
locator = self._resolve_locator( binding = self._resolve_binding(
tenant_id=tenant_id, tenant_id=tenant_id,
app_id=app_id, app_id=app_id,
workflow_run_id=workflow_run_id, workflow_run_id=workflow_run_id,
@@ -165,7 +285,8 @@ class WorkflowAgentSandboxService:
node_execution_id=node_execution_id, node_execution_id=node_execution_id,
session=session, session=session,
) )
return self._client_factory().read_sandbox_file_sync(locator, path) with self._client_factory() as client:
return client.read_workspace_file_sync(binding.backend_binding_ref, path)
def upload_file( def upload_file(
self, self,
@@ -174,11 +295,11 @@ class WorkflowAgentSandboxService:
app_id: str, app_id: str,
workflow_run_id: str, workflow_run_id: str,
node_id: str, node_id: str,
node_execution_id: str | None, node_execution_id: str,
path: str, path: str,
session: Session, session: Session,
) -> AgentSandboxUploadDownload: ) -> AgentSandboxUploadDownload:
locator = self._resolve_locator( binding = self._resolve_binding(
tenant_id=tenant_id, tenant_id=tenant_id,
app_id=app_id, app_id=app_id,
workflow_run_id=workflow_run_id, workflow_run_id=workflow_run_id,
@@ -186,118 +307,97 @@ class WorkflowAgentSandboxService:
node_execution_id=node_execution_id, node_execution_id=node_execution_id,
session=session, session=session,
) )
uploaded = self._client_factory().upload_sandbox_file_sync(locator, path) with self._client_factory() as client:
return _upload_download_response( uploaded = client.upload_workspace_file_sync(
tenant_id=tenant_id, WorkspaceUploadRequest(
file_mapping=uploaded.file.model_dump(mode="python"), backend_binding_ref=binding.backend_binding_ref,
) path=path,
execution_context=DifyExecutionContextLayerConfig(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
agent_id=binding.agent_id,
agent_config_version_id=binding.agent_config_version_id,
agent_config_version_kind=cast(
DifyExecutionContextAgentConfigVersionKind,
binding.agent_config_version_kind.value,
),
agent_mode="workflow_run",
invoke_from="debugger",
),
)
)
return _upload_download_response(tenant_id=tenant_id, file_mapping=uploaded.file.model_dump(mode="python"))
def _resolve_locator( @staticmethod
self, def _resolve_binding(
*, *,
tenant_id: str, tenant_id: str,
app_id: str, app_id: str,
workflow_run_id: str, workflow_run_id: str,
node_id: str, node_id: str,
node_execution_id: str | None, node_execution_id: str,
session: Session, session: Session,
) -> SandboxLocator: ) -> AgentWorkspaceBinding:
"""Resolve one workflow Agent sandbox from product-facing identifiers. execution = session.scalar(
select(WorkflowNodeExecutionModel).where(
Callers may target either a specific node execution or the current node WorkflowNodeExecutionModel.id == node_execution_id,
as a whole. When ``node_execution_id`` is provided, lookup narrows to WorkflowNodeExecutionModel.tenant_id == tenant_id,
that execution's ACTIVE runtime-session row. When it is omitted, the WorkflowNodeExecutionModel.app_id == app_id,
service falls back to the most recently updated ACTIVE session for the WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id,
same ``workflow_run_id + node_id`` pair so console sandbox inspection can WorkflowNodeExecutionModel.node_id == node_id,
still work from the broader workflow/node locator. )
"""
stmt = select(WorkflowAgentRuntimeSession).where(
WorkflowAgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
WorkflowAgentRuntimeSession.tenant_id == tenant_id,
WorkflowAgentRuntimeSession.app_id == app_id,
WorkflowAgentRuntimeSession.workflow_run_id == workflow_run_id,
WorkflowAgentRuntimeSession.node_id == node_id,
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE,
) )
if node_execution_id: process_data = execution.process_data_dict if execution is not None else None
stmt = stmt.where(WorkflowAgentRuntimeSession.node_execution_id == node_execution_id) workflow_agent_binding_id = process_data.get("workflow_agent_binding_id") if process_data is not None else None
stmt = stmt.order_by(WorkflowAgentRuntimeSession.updated_at.desc()).limit(1) if (
execution is None
row = session.scalar(stmt) or execution.agent_workspace_binding_id is None
or not isinstance(workflow_agent_binding_id, str)
if row is None: ):
raise AgentSandboxInspectorError( raise AgentSandboxInspectorError(
"no_active_session", "no_active_binding",
"this workflow Agent node has no active sandbox session yet", "this Workflow Agent node execution has no active Workspace Binding",
status_code=404, status_code=404,
) )
return _build_locator_or_raise( binding = AgentWorkspaceService.get_active_binding(
snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), session=session,
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs), tenant_id=tenant_id,
not_found_message="this workflow Agent node has no sandbox workspace", binding_id=execution.agent_workspace_binding_id,
expected_owner_scope=WorkspaceOwnerScope(
tenant_id=tenant_id,
app_id=app_id,
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
owner_id=workflow_run_id,
owner_scope_key=f"{node_id}:{workflow_agent_binding_id}",
),
) )
if binding is None:
raise AgentSandboxInspectorError(
def _build_locator_or_raise( "no_active_binding",
*, "this Workflow Agent node execution has no active Workspace Binding",
snapshot: CompositorSessionSnapshot, status_code=404,
runtime_layer_specs: list[RuntimeLayerSpec], )
not_found_message: str, return binding
) -> SandboxLocator:
try:
return build_sandbox_locator_from_layer_specs(
layer_specs=runtime_layer_specs,
session_snapshot=snapshot,
)
except ValueError as exc:
raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404) from exc
def _extract_shell_workspace_or_raise(
*,
snapshot: CompositorSessionSnapshot,
not_found_message: str,
) -> tuple[str, str]:
shell_layer = next((layer for layer in snapshot.layers if layer.name == "shell"), None)
if shell_layer is None:
raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404)
session_id = shell_layer.runtime_state.get("session_id")
workspace_cwd = shell_layer.runtime_state.get("workspace_cwd")
if not isinstance(session_id, str) or not isinstance(workspace_cwd, str):
raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404)
return session_id, workspace_cwd
def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]:
if not value:
return []
return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value)
def _upload_download_response(*, tenant_id: str, file_mapping: dict[str, Any]) -> AgentSandboxUploadDownload: def _upload_download_response(*, tenant_id: str, file_mapping: dict[str, Any]) -> AgentSandboxUploadDownload:
"""Resolve one uploaded ToolFile mapping into a signed external download URL."""
controller = DatabaseFileAccessController() controller = DatabaseFileAccessController()
runtime = DifyWorkflowFileRuntime(file_access_controller=controller) runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
try: try:
file = file_factory.build_from_mapping( file = file_factory.build_from_mapping(mapping=file_mapping, tenant_id=tenant_id, access_controller=controller)
mapping=file_mapping,
tenant_id=tenant_id,
access_controller=controller,
)
url = runtime.resolve_file_url(file=file, for_external=True) url = runtime.resolve_file_url(file=file, for_external=True)
except ValueError as exc: except ValueError as exc:
raise AgentSandboxInspectorError( raise AgentSandboxInspectorError(
"sandbox_upload_download_unavailable", "workspace_upload_download_unavailable",
"uploaded sandbox file could not be converted to a download URL", "uploaded Workspace file could not be converted to a download URL",
status_code=502, status_code=502,
) from exc ) from exc
if not url: if not url:
raise AgentSandboxInspectorError( raise AgentSandboxInspectorError(
"sandbox_upload_download_unavailable", "workspace_upload_download_unavailable",
"uploaded sandbox file does not support download URL generation", "uploaded Workspace file does not support download URL generation",
status_code=502, status_code=502,
) )
return AgentSandboxUploadDownload(url=_with_as_attachment(url)) return AgentSandboxUploadDownload(url=_with_as_attachment(url))
@@ -315,7 +415,7 @@ def _default_client_factory() -> Client:
if not base_url: if not base_url:
raise AgentSandboxInspectorError( raise AgentSandboxInspectorError(
"inspector_unavailable", "inspector_unavailable",
"the sandbox file inspector is not available (agent backend not configured)", "the Workspace file inspector is not available (Agent backend not configured)",
status_code=503, status_code=503,
) )
return Client(base_url=base_url) return Client(base_url=base_url)
+14 -1
View File
@@ -41,6 +41,7 @@ from models import Account, App, AppMode
from models.model import AppModelConfig, AppModelConfigDict, IconType, load_annotation_reply_config from models.model import AppModelConfig, AppModelConfigDict, IconType, load_annotation_reply_config
from models.workflow import Workflow from models.workflow import Workflow
from services.agent.dsl_service import AgentDslService, AgentPackage from services.agent.dsl_service import AgentDslService, AgentPackage
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.agent.workflow_publish_service import WorkflowAgentPublishService
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
from services.dsl_version import check_version_compatibility from services.dsl_version import check_version_compatibility
@@ -51,6 +52,7 @@ from services.errors.app import WorkflowNotFoundError
from services.plugin.dependencies_analysis import DependenciesAnalysisService from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.workflow_draft_variable_service import WorkflowDraftVariableService from services.workflow_draft_variable_service import WorkflowDraftVariableService
from services.workflow_service import WorkflowService from services.workflow_service import WorkflowService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -546,7 +548,7 @@ class AppDslService:
sync_agent_bindings=not raw_agent_packages, sync_agent_bindings=not raw_agent_packages,
) )
if raw_agent_packages: if raw_agent_packages:
_, warnings = AgentDslService(self._session).import_workflow_packages( _, warnings, retirement_candidates = AgentDslService(self._session).import_workflow_packages(
workflow=draft_workflow, workflow=draft_workflow,
portable_graph=graph, portable_graph=graph,
raw_packages=raw_agent_packages, raw_packages=raw_agent_packages,
@@ -557,6 +559,17 @@ class AppDslService:
session=self._session, session=self._session,
draft_workflow=draft_workflow, draft_workflow=draft_workflow,
) )
self._session.commit()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=app.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=app.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
case AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION: case AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION:
# Initialize model config # Initialize model config
model_config = data.get("model_config") model_config = data.get("model_config")
+73 -5
View File
@@ -26,17 +26,29 @@ from libs.datetime_utils import naive_utc_now
from libs.login import current_user from libs.login import current_user
from libs.pagination import PaginatedResult, paginate_query from libs.pagination import PaginatedResult, paginate_query
from models import Account, AppStar from models import Account, AppStar
from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentIconType, AgentScope, AgentStatus from models.agent import (
APP_BACKED_AGENT_SOURCES,
Agent,
AgentIconType,
AgentScope,
AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspaceBinding,
)
from models.model import App, AppMode, AppModelConfig, IconType, Site, load_annotation_reply_config from models.model import App, AppMode, AppModelConfig, IconType, Site, load_annotation_reply_config
from models.tools import ApiToolProvider from models.tools import ApiToolProvider
from models.workflow import Workflow from models.workflow import Workflow
from services.agent.errors import AgentNameConflictError from services.agent.errors import AgentNameConflictError
from services.agent.home_snapshot_service import AgentHomeSnapshotService
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.agent.workspace_service import AgentWorkspaceService
from services.billing_service import BillingService from services.billing_service import BillingService
from services.enterprise import rbac_service as enterprise_rbac_service from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService from services.feature_service import FeatureService
from services.openapi.visibility import apply_openapi_gate, is_openapi_visible from services.openapi.visibility import apply_openapi_gate, is_openapi_visible
from services.tag_service import TagService from services.tag_service import TagService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -114,7 +126,7 @@ class AppModelConfigResponseView:
self._session = session self._session = session
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
return getattr(self._app_model_config, name) # noqa: no-new-getattr response adapter delegates model fields return getattr(self._app_model_config, name) # guard-ignore: no-new-getattr -- delegates model fields
@property @property
def annotation_reply_dict(self) -> Any: def annotation_reply_dict(self) -> Any:
@@ -129,7 +141,7 @@ class AppResponseView:
self._session = session self._session = session
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
return getattr(self._app, name) # noqa: no-new-getattr response adapter delegates model fields return getattr(self._app, name) # guard-ignore: no-new-getattr -- delegates model fields
@property @property
def desc_or_prompt(self) -> str: def desc_or_prompt(self) -> str:
@@ -478,7 +490,14 @@ class AppService:
session.delete(existing_star) session.delete(existing_star)
def create_app(self, tenant_id: str, params: CreateAppParams, account: Account, *, session: Session) -> App: def create_app(
self,
tenant_id: str,
params: CreateAppParams,
account: Account,
*,
session: Session,
) -> App:
""" """
Create app Create app
:param tenant_id: tenant id :param tenant_id: tenant id
@@ -943,18 +962,67 @@ class AppService:
app_was_deleted.send(app) app_was_deleted.send(app)
backing_agent = self._get_backing_agent_for_update(app, session=session) backing_agent = self._get_backing_agent_for_update(app, session=session)
workflow_agent_ids = session.scalars(
select(Agent.id).where(
Agent.tenant_id == app.tenant_id,
Agent.app_id == app.id,
Agent.scope == AgentScope.WORKFLOW_ONLY,
Agent.status == AgentStatus.ACTIVE,
)
).all()
account_id = current_user.id if current_user else None
if backing_agent is not None: if backing_agent is not None:
now = naive_utc_now() now = naive_utc_now()
account_id = getattr(current_user, "id", None)
backing_agent.status = AgentStatus.ARCHIVED backing_agent.status = AgentStatus.ARCHIVED
backing_agent.archived_by = account_id backing_agent.archived_by = account_id
backing_agent.archived_at = now backing_agent.archived_at = now
backing_agent.updated_by = account_id backing_agent.updated_by = account_id
backing_agent.updated_at = now backing_agent.updated_at = now
retired_binding_ids: list[str] = []
retired_snapshot_ids: list[str] = []
if backing_agent is not None:
bindings = session.scalars(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.tenant_id == app.tenant_id,
AgentWorkspaceBinding.agent_id == backing_agent.id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
for binding in bindings:
binding_id = AgentWorkspaceService.retire_binding(
session=session,
tenant_id=app.tenant_id,
binding_id=binding.id,
)
if binding_id is not None:
retired_binding_ids.append(binding_id)
retired_snapshot_ids = AgentHomeSnapshotService.retire_all_for_agent(
session=session,
tenant_id=app.tenant_id,
agent_id=backing_agent.id,
)
retired_workspace_ids = AgentWorkspaceService.retire_all_for_app(
session=session,
tenant_id=app.tenant_id,
app_id=app.id,
)
session.delete(app) session.delete(app)
session.commit() session.commit()
workflow_binding_ids, workflow_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=app.tenant_id,
agent_ids=workflow_agent_ids,
account_id=account_id,
)
enqueue_agent_resource_collection(
tenant_id=app.tenant_id,
workspace_ids=retired_workspace_ids,
binding_ids=[*retired_binding_ids, *workflow_binding_ids],
home_snapshot_ids=[*retired_snapshot_ids, *workflow_snapshot_ids],
)
# clean up web app settings # clean up web app settings
if FeatureService.get_system_features().webapp_auth.enabled: if FeatureService.get_system_features().webapp_auth.enabled:
EnterpriseService.WebAppAuth.cleanup_webapp(app.id) EnterpriseService.WebAppAuth.cleanup_webapp(app.id)
+36 -70
View File
@@ -6,9 +6,7 @@ from typing import Any
from sqlalchemy import asc, desc, func, or_, select from sqlalchemy import asc, desc, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from clients.agent_backend import AgentBackendSessionCleanupPayload
from configs import dify_config 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.app.entities.app_invoke_entities import InvokeFrom
from core.llm_generator.llm_generator import LLMGenerator from core.llm_generator.llm_generator import LLMGenerator
from factories import variable_factory from factories import variable_factory
@@ -16,7 +14,9 @@ from graphon.variables.types import SegmentType
from libs.datetime_utils import naive_utc_now from libs.datetime_utils import naive_utc_now
from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models import Account, ConversationVariable from models import Account, ConversationVariable
from models.agent import AgentWorkspaceOwnerType
from models.model import App, Conversation, EndUser, Message from models.model import App, Conversation, EndUser, Message
from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentWorkspaceService, WorkspaceOwnerScope
from services.errors.conversation import ( from services.errors.conversation import (
ConversationNotExistsError, ConversationNotExistsError,
ConversationVariableNotExistsError, ConversationVariableNotExistsError,
@@ -24,7 +24,7 @@ from services.errors.conversation import (
LastConversationNotExistsError, LastConversationNotExistsError,
) )
from services.errors.message import MessageNotExistsError from services.errors.message import MessageNotExistsError
from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
from tasks.delete_conversation_task import delete_conversation_related_data from tasks.delete_conversation_task import delete_conversation_related_data
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -189,23 +189,30 @@ class ConversationService:
""" """
Delete a conversation only if it belongs to the given user and app context. Delete a conversation only if it belongs to the given user and app context.
Before removing the conversation row, this best-effort lifecycle path Conversation deletion is the product lifecycle boundary for its
enumerates any ACTIVE conversation-owned Agent backend runtime sessions, Workspace. Physical collection happens only after the retire commit.
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: Raises:
ConversationNotExistsError: When the conversation is not visible to the current user. ConversationNotExistsError: When the conversation is not visible to the current user.
""" """
conversation = cls.get_conversation(app_model, conversation_id, user, session=session) conversation = cls.get_conversation(app_model, conversation_id, user, session=session)
session_store = AgentAppRuntimeSessionStore() binding_id = conversation.agent_workspace_binding_id
stored_sessions = session_store.list_active_sessions_for_conversation( retired_binding_id: str | None = None
tenant_id=app_model.tenant_id, if binding_id is not None:
app_id=app_model.id, owner_scope = WorkspaceOwnerScope(
conversation_id=conversation.id, tenant_id=app_model.tenant_id,
) app_id=app_model.id,
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id=conversation.id,
)
binding = AgentWorkspaceService.get_active_binding(
session=session,
tenant_id=app_model.tenant_id,
binding_id=binding_id,
expected_owner_scope=owner_scope,
)
if binding is None:
raise AgentWorkspaceNotFoundError("Conversation participant Binding is unavailable")
try: try:
logger.info( logger.info(
@@ -213,66 +220,25 @@ class ConversationService:
app_model.name, app_model.name,
conversation_id, conversation_id,
) )
for stored_session in stored_sessions: if binding_id is not None:
try: retired_binding_id = AgentWorkspaceService.retire_binding(
if stored_session.runtime_layer_specs: session=session,
payload = AgentBackendSessionCleanupPayload( tenant_id=app_model.tenant_id,
session_snapshot=stored_session.session_snapshot, binding_id=binding_id,
runtime_layer_specs=stored_session.runtime_layer_specs, )
idempotency_key=( if retired_binding_id is None:
f"{stored_session.scope.tenant_id}:{stored_session.scope.app_id}:" raise AgentWorkspaceNotFoundError("Conversation participant Binding is unavailable")
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.delete(conversation)
session.commit() session.commit()
delete_conversation_related_data.delay(conversation.id)
except Exception: except Exception:
session.rollback() session.rollback()
raise raise
if retired_binding_id is not None:
enqueue_agent_resource_collection(
tenant_id=app_model.tenant_id,
binding_ids=(retired_binding_id,),
)
delete_conversation_related_data.delay(conversation.id)
@classmethod @classmethod
def get_conversational_variable( def get_conversational_variable(
+13 -1
View File
@@ -26,6 +26,7 @@ from models import Account, ApiToken, Tenant, TenantAccountJoin, TenantAccountRo
from models.enums import ApiTokenType from models.enums import ApiTokenType
from models.model import App from models.model import App
from models.tools import ApiToolProvider, MCPToolProvider, WorkflowToolProvider from models.tools import ApiToolProvider, MCPToolProvider, WorkflowToolProvider
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.app_dsl_service import AppDslService from services.app_dsl_service import AppDslService
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService from services.data_migration.dependency_discovery_service import DependencyDiscoveryService
from services.data_migration.entities import ( from services.data_migration.entities import (
@@ -47,6 +48,7 @@ from services.tools.api_tools_manage_service import ApiToolManageService
from services.tools.mcp_tools_manage_service import MCPToolManageService from services.tools.mcp_tools_manage_service import MCPToolManageService
from services.tools.workflow_tools_manage_service import WorkflowToolManageService from services.tools.workflow_tools_manage_service import WorkflowToolManageService
from services.workflow_service import WorkflowService from services.workflow_service import WorkflowService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -711,7 +713,7 @@ class MigrationImportService:
raise MigrationDataError(f"Referenced workflow app was not found in target tenant: {app_id}") raise MigrationDataError(f"Referenced workflow app was not found in target tenant: {app_id}")
if account_in_session is None: if account_in_session is None:
raise MigrationDataError(f"Operator account not found: {account.id}") raise MigrationDataError(f"Operator account not found: {account.id}")
workflow = workflow_service.publish_workflow( workflow, retirement_candidates = workflow_service.publish_workflow(
session=session, session=session,
app_model=app_in_session, app_model=app_in_session,
account=account_in_session, account=account_in_session,
@@ -721,6 +723,16 @@ class MigrationImportService:
app_in_session.workflow_id = workflow.id app_in_session.workflow_id = workflow.id
app_in_session.updated_by = account.id app_in_session.updated_by = account.id
app_in_session.updated_at = naive_utc_now() app_in_session.updated_at = naive_utc_now()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=target.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=target.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
def _import_mcp_tools( def _import_mcp_tools(
self, self,
+26 -2
View File
@@ -19,12 +19,14 @@ from models import Account
from models.snippet import CustomizedSnippet, SnippetType from models.snippet import CustomizedSnippet, SnippetType
from models.workflow import Workflow from models.workflow import Workflow
from services.agent.dsl_service import AgentDslService from services.agent.dsl_service import AgentDslService
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.agent.workflow_publish_service import WorkflowAgentPublishService
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
from services.dsl_version import check_version_compatibility from services.dsl_version import check_version_compatibility
from services.entities.dsl_entities import CheckDependenciesResult, DslImportWarning, ImportMode, ImportStatus from services.entities.dsl_entities import CheckDependenciesResult, DslImportWarning, ImportMode, ImportStatus
from services.plugin.dependencies_analysis import DependenciesAnalysisService from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -426,6 +428,7 @@ class SnippetDslService:
self._session.flush() self._session.flush()
# Create or update draft workflow # Create or update draft workflow
retirement_candidates: set[str] = set()
if workflow_data: if workflow_data:
graph = workflow_data.get("graph", {}) graph = workflow_data.get("graph", {})
raw_agent_packages = data.get("agent_packages") or {} raw_agent_packages = data.get("agent_packages") or {}
@@ -444,10 +447,10 @@ class SnippetDslService:
unique_hash=unique_hash, unique_hash=unique_hash,
account=account, account=account,
input_fields=input_fields, input_fields=input_fields,
sync_agent_bindings=not raw_agent_packages, sync_agent_bindings=False,
) )
if raw_agent_packages: if raw_agent_packages:
_, warnings = AgentDslService(self._session).import_workflow_packages( _, warnings, retirement_candidates = AgentDslService(self._session).import_workflow_packages(
workflow=draft_workflow, workflow=draft_workflow,
portable_graph=graph, portable_graph=graph,
raw_packages=raw_agent_packages, raw_packages=raw_agent_packages,
@@ -458,8 +461,29 @@ class SnippetDslService:
session=self._session, session=self._session,
draft_workflow=draft_workflow, draft_workflow=draft_workflow,
) )
else:
retirement_candidates = WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=self._session,
draft_workflow=draft_workflow,
account_id=account.id,
)
WorkflowAgentPublishService.validate_agent_nodes_for_draft_sync(
session=self._session,
draft_workflow=draft_workflow,
)
self._session.commit() self._session.commit()
if workflow_data:
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=snippet.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=snippet.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return snippet return snippet
def export_snippet_dsl(self, snippet: CustomizedSnippet, include_secret: bool = False) -> str: def export_snippet_dsl(self, snippet: CustomizedSnippet, include_secret: bool = False) -> str:
+32 -7
View File
@@ -35,6 +35,7 @@ from models.workflow import (
WorkflowType, WorkflowType,
) )
from repositories.factory import DifyAPIRepositoryFactory from repositories.factory import DifyAPIRepositoryFactory
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.tag_service import TagService from services.tag_service import TagService
from services.workflow_node_execution_trace_service import ( from services.workflow_node_execution_trace_service import (
@@ -42,6 +43,7 @@ from services.workflow_node_execution_trace_service import (
assemble_workflow_node_execution_traces, assemble_workflow_node_execution_traces,
) )
from services.workflow_restore import apply_published_workflow_snapshot_to_draft from services.workflow_restore import apply_published_workflow_snapshot_to_draft
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -80,7 +82,7 @@ class SnippetService:
@contextmanager @contextmanager
def _session_scope(self) -> Generator[Session, None, None]: def _session_scope(self) -> Generator[Session, None, None]:
current_session = getattr(self, "_session", None) current_session = self._session
if current_session is not None: if current_session is not None:
yield current_session yield current_session
return return
@@ -89,7 +91,7 @@ class SnippetService:
yield session yield session
def _commit_if_owned(self, session: Session) -> None: def _commit_if_owned(self, session: Session) -> None:
if getattr(self, "_session", None) is None: if self._session is None:
session.commit() session.commit()
@staticmethod @staticmethod
@@ -600,12 +602,13 @@ class SnippetService:
from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.agent.workflow_publish_service import WorkflowAgentPublishService
retirement_candidates: set[str] = set()
with self._session_scope() as session: with self._session_scope() as session:
session.add(workflow) session.add(workflow)
session.add(snippet) session.add(snippet)
if sync_agent_bindings: if sync_agent_bindings:
session.flush() session.flush()
WorkflowAgentPublishService.sync_agent_bindings_for_draft( retirement_candidates = WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=session, session=session,
draft_workflow=workflow, draft_workflow=workflow,
account_id=account.id, account_id=account.id,
@@ -615,6 +618,17 @@ class SnippetService:
draft_workflow=workflow, draft_workflow=workflow,
) )
self._commit_if_owned(session) self._commit_if_owned(session)
if self._session is None:
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=snippet.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=snippet.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return workflow return workflow
def restore_published_workflow_to_draft( def restore_published_workflow_to_draft(
@@ -656,13 +670,24 @@ class SnippetService:
session.flush() session.flush()
from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.agent.workflow_publish_service import WorkflowAgentPublishService
WorkflowAgentPublishService.restore_agent_node_bindings_to_draft( retirement_candidates = WorkflowAgentPublishService.restore_agent_node_bindings_to_draft(
session=session, session=session,
source_workflow=source_workflow, source_workflow=source_workflow,
draft_workflow=draft_workflow, draft_workflow=draft_workflow,
account_id=account.id, account_id=account.id,
) )
self._commit_if_owned(session) self._commit_if_owned(session)
if self._session is None:
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=snippet.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=snippet.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return draft_workflow return draft_workflow
def publish_workflow( def publish_workflow(
@@ -671,7 +696,7 @@ class SnippetService:
session: Session, session: Session,
snippet: CustomizedSnippet, snippet: CustomizedSnippet,
account: Account, account: Account,
) -> Workflow: ) -> tuple[Workflow, set[str]]:
""" """
Publish the draft workflow as a new version. Publish the draft workflow as a new version.
@@ -715,7 +740,7 @@ class SnippetService:
kind=WorkflowKind.SNIPPET.value, kind=WorkflowKind.SNIPPET.value,
) )
session.add(workflow) session.add(workflow)
WorkflowAgentPublishService.copy_agent_node_bindings_to_published( retirement_candidates = WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
session=session, session=session,
draft_workflow=draft_workflow, draft_workflow=draft_workflow,
published_workflow=workflow, published_workflow=workflow,
@@ -728,7 +753,7 @@ class SnippetService:
snippet.updated_by = account.id snippet.updated_by = account.id
session.add(snippet) session.add(snippet)
return workflow return workflow, retirement_candidates
def get_all_published_workflows( def get_all_published_workflows(
self, self,
+28 -5
View File
@@ -76,6 +76,7 @@ from models.model import App, AppMode
from models.tools import WorkflowToolProvider from models.tools import WorkflowToolProvider
from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowType from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowType
from repositories.factory import DifyAPIRepositoryFactory from repositories.factory import DifyAPIRepositoryFactory
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.billing_service import BillingService from services.billing_service import BillingService
from services.errors.app import ( from services.errors.app import (
IsDraftWorkflowError, IsDraftWorkflowError,
@@ -83,6 +84,7 @@ from services.errors.app import (
WorkflowHashNotEqualError, WorkflowHashNotEqualError,
WorkflowNotFoundError, WorkflowNotFoundError,
) )
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -374,8 +376,9 @@ class WorkflowService:
from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.agent.workflow_publish_service import WorkflowAgentPublishService
session.flush() session.flush()
retirement_candidates: set[str] = set()
if sync_agent_bindings: if sync_agent_bindings:
WorkflowAgentPublishService.sync_agent_bindings_for_draft( retirement_candidates = WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=session, session=session,
draft_workflow=workflow, draft_workflow=workflow,
account_id=account.id, account_id=account.id,
@@ -388,6 +391,16 @@ class WorkflowService:
# commit db session changes # commit db session changes
if commit: if commit:
session.commit() session.commit()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=app_model.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=app_model.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
# trigger app workflow events # trigger app workflow events
if commit: if commit:
@@ -509,7 +522,7 @@ class WorkflowService:
from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.agent.workflow_publish_service import WorkflowAgentPublishService
session.flush() session.flush()
WorkflowAgentPublishService.restore_agent_node_bindings_to_draft( retirement_candidates = WorkflowAgentPublishService.restore_agent_node_bindings_to_draft(
session=session, session=session,
source_workflow=source_workflow, source_workflow=source_workflow,
draft_workflow=draft_workflow, draft_workflow=draft_workflow,
@@ -517,6 +530,16 @@ class WorkflowService:
) )
session.commit() session.commit()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=app_model.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=app_model.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
app_draft_workflow_was_synced.send(app_model, synced_draft_workflow=draft_workflow) app_draft_workflow_was_synced.send(app_model, synced_draft_workflow=draft_workflow)
return draft_workflow return draft_workflow
@@ -529,7 +552,7 @@ class WorkflowService:
account: Account, account: Account,
marked_name: str = "", marked_name: str = "",
marked_comment: str = "", marked_comment: str = "",
) -> Workflow: ) -> tuple[Workflow, set[str]]:
draft_workflow_stmt = select(Workflow).where( draft_workflow_stmt = select(Workflow).where(
Workflow.tenant_id == app_model.tenant_id, Workflow.tenant_id == app_model.tenant_id,
Workflow.app_id == app_model.id, Workflow.app_id == app_model.id,
@@ -588,7 +611,7 @@ class WorkflowService:
# commit db session changes # commit db session changes
session.add(workflow) session.add(workflow)
WorkflowAgentPublishService.copy_agent_node_bindings_to_published( retirement_candidates = WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
session=session, session=session,
draft_workflow=draft_workflow, draft_workflow=draft_workflow,
published_workflow=workflow, published_workflow=workflow,
@@ -598,7 +621,7 @@ class WorkflowService:
app_published_workflow_was_updated.send(app_model, published_workflow=workflow) app_published_workflow_was_updated.send(app_model, published_workflow=workflow)
# return new workflow # return new workflow
return workflow return workflow, retirement_candidates
def _validate_workflow_credentials(self, workflow: Workflow, *, session: Session) -> None: def _validate_workflow_credentials(self, workflow: Workflow, *, session: Session) -> None:
""" """
@@ -1,72 +0,0 @@
"""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,
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
stream_max_reconnects=dify_config.AGENT_BACKEND_STREAM_MAX_RECONNECTS,
stream_run_timeout_seconds=dify_config.AGENT_BACKEND_RUN_TIMEOUT_SECONDS,
)
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)
@@ -51,6 +51,7 @@ def resume_agent_app_execution(*, conversation_id: str, form_id: str) -> None:
app_model=app_model, app_model=app_model,
user=user, user=user,
conversation_id=conversation_id, conversation_id=conversation_id,
form_id=form_id,
invoke_from=_resolve_invoke_from(conversation), invoke_from=_resolve_invoke_from(conversation),
session=db.session(), session=db.session(),
) )
+75
View File
@@ -0,0 +1,75 @@
"""Asynchronously collect retired Agent working resources."""
from __future__ import annotations
import logging
from collections.abc import Iterable
from celery import shared_task
from services.agent.home_snapshot_service import AgentHomeSnapshotService
from services.agent.workspace_service import AgentWorkspaceService
logger = logging.getLogger(__name__)
@shared_task(queue="retention")
def collect_agent_resources(
*,
tenant_id: str,
binding_ids: list[str],
workspace_ids: list[str],
home_snapshot_ids: list[str],
) -> None:
"""Collect only the explicitly identified RETIRED resources."""
collectors = (
(workspace_ids, "workspace_id", AgentWorkspaceService.collect_retired_workspace),
(binding_ids, "binding_id", AgentWorkspaceService.collect_retired_binding),
(
home_snapshot_ids,
"home_snapshot_id",
AgentHomeSnapshotService.collect_retired_home_snapshot,
),
)
for resource_ids, argument_name, collector in collectors:
for resource_id in resource_ids:
try:
collector(tenant_id=tenant_id, **{argument_name: resource_id})
except Exception:
logger.exception(
"Failed to collect retired Agent resource",
extra={
"tenant_id": tenant_id,
"resource_type": argument_name.removesuffix("_id"),
"resource_id": resource_id,
},
)
def enqueue_agent_resource_collection(
*,
tenant_id: str,
binding_ids: Iterable[str] = (),
workspace_ids: Iterable[str] = (),
home_snapshot_ids: Iterable[str] = (),
) -> None:
"""Best-effort enqueue of physical collection after retire has committed."""
payload = {
"binding_ids": sorted({resource_id for resource_id in binding_ids if resource_id}),
"workspace_ids": sorted({resource_id for resource_id in workspace_ids if resource_id}),
"home_snapshot_ids": sorted({resource_id for resource_id in home_snapshot_ids if resource_id}),
}
if not any(payload.values()):
return
try:
collect_agent_resources.delay(tenant_id=tenant_id, **payload)
except Exception:
logger.exception(
"Failed to enqueue retired Agent resource collection",
extra={"tenant_id": tenant_id, **payload},
)
__all__ = ["collect_agent_resources", "enqueue_agent_resource_collection"]
@@ -5,25 +5,17 @@ from typing import Any, cast
import click import click
import sqlalchemy as sa import sqlalchemy as sa
from agenton.compositor import CompositorSessionSnapshot
from celery import shared_task from celery import shared_task
from dify_agent.protocol import RuntimeLayerSpec
from pydantic import JsonValue, TypeAdapter
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.engine import CursorResult from sqlalchemy.engine import CursorResult
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload
from configs import dify_config from configs import dify_config
from core.db.session_factory import session_factory from core.db.session_factory import session_factory
from extensions.ext_database import db from extensions.ext_database import db
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
from libs.datetime_utils import naive_utc_now
from models import ( from models import (
AgentRuntimeSession,
AgentRuntimeSessionOwnerType,
AgentRuntimeSessionStatus,
ApiToken, ApiToken,
AppAnnotationHitHistory, AppAnnotationHitHistory,
AppAnnotationSetting, AppAnnotationSetting,
@@ -58,13 +50,8 @@ from models.workflow import (
) )
from repositories.factory import DifyAPIRepositoryFactory from repositories.factory import DifyAPIRepositoryFactory
from services.api_token_service import ApiTokenCache 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__) logger = logging.getLogger(__name__)
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
@shared_task(queue="app_deletion", bind=True, max_retries=3) @shared_task(queue="app_deletion", bind=True, max_retries=3)
@@ -72,7 +59,6 @@ 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")) logger.info(click.style(f"Start deleting app and related data: {tenant_id}:{app_id}", fg="green"))
start_at = time.perf_counter() start_at = time.perf_counter()
try: try:
_cleanup_active_agent_runtime_sessions_for_app(tenant_id, app_id)
# Delete related data # Delete related data
_delete_app_model_configs(tenant_id, app_id) _delete_app_model_configs(tenant_id, app_id)
_delete_app_site(tenant_id, app_id) _delete_app_site(tenant_id, app_id)
@@ -113,143 +99,6 @@ 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 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 _delete_app_model_configs(tenant_id: str, app_id: str):
def del_model_config(session, model_config_id: str): def del_model_config(session, model_config_id: str):
session.execute( session.execute(
+3 -1
View File
@@ -13,6 +13,7 @@ from celery import shared_task
from sqlalchemy import select from sqlalchemy import select
from core.db.session_factory import session_factory from core.db.session_factory import session_factory
from core.workflow.node_execution_process_data import preserve_workflow_agent_binding_id
from graphon.entities.workflow_node_execution import ( from graphon.entities.workflow_node_execution import (
WorkflowNodeExecution, WorkflowNodeExecution,
) )
@@ -144,8 +145,9 @@ def _update_node_execution_from_domain(node_execution: WorkflowNodeExecutionMode
# Update serialized data # Update serialized data
json_converter = WorkflowRuntimeTypeConverter() json_converter = WorkflowRuntimeTypeConverter()
node_execution.inputs = json.dumps(json_converter.to_json_encodable(execution.inputs)) if execution.inputs else "{}" node_execution.inputs = json.dumps(json_converter.to_json_encodable(execution.inputs)) if execution.inputs else "{}"
process_data = preserve_workflow_agent_binding_id(node_execution.process_data_dict, execution.process_data)
node_execution.process_data = ( node_execution.process_data = (
json.dumps(json_converter.to_json_encodable(execution.process_data)) if execution.process_data else "{}" json.dumps(json_converter.to_json_encodable(process_data)) if process_data is not None else "{}"
) )
node_execution.outputs = ( node_execution.outputs = (
json.dumps(json_converter.to_json_encodable(execution.outputs)) if execution.outputs else "{}" json.dumps(json_converter.to_json_encodable(execution.outputs)) if execution.outputs else "{}"
@@ -2,114 +2,94 @@ extend = "../../.ruff.toml"
src = ["../.."] src = ["../.."]
[lint] [lint]
extend-select = ["ANN401", "ARG", "TID251"] extend-select = ["ANN401", "ARG"]
# Existing strict-mode debt. Remove a file entry when bringing it under strict checking.
[lint.per-file-ignores] [lint.per-file-ignores]
"core/rag/pipeline/test_queue_integration.py" = ["ANN401", "TID251", "ARG"] "controllers/console/test_apikey.py" = ["ARG002"]
"controllers/openapi/test_app_dsl.py" = ["ARG002"]
"controllers/service_api/dataset/test_dataset.py" = ["ARG002"]
"controllers/web/test_conversation.py" = ["ARG002"]
"controllers/web/test_human_input_form.py" = ["ARG001"]
"controllers/web/test_wraps.py" = ["ARG002"]
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG002"]
"core/rag/pipeline/test_queue_integration.py" = ["ARG002", "TID251"]
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG002"]
"models/test_conversation_message_inputs.py" = ["ARG001"]
"models/test_types_enum_text.py" = ["ANN401", "TID251"] "models/test_types_enum_text.py" = ["ANN401", "TID251"]
"services/test_app_dsl_service.py" = ["ANN401", "TID251", "ARG"] "repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG002", "ARG005"]
"services/test_file_service_zip_and_lookup.py" = ["ANN401", "TID251", "ARG"] "repositories/test_workflow_run_repository.py" = ["ARG002"]
"trigger/conftest.py" = ["ANN401", "TID251"] "services/auth/test_auth_integration.py" = ["ARG002"]
"trigger/test_trigger_e2e.py" = ["ANN401", "TID251", "ARG"] "services/dataset_collection_binding.py" = ["ARG002"]
"controllers/console/app/test_app_apis.py" = ["ARG"] "services/document_service_status.py" = ["ARG002"]
"controllers/console/app/test_app_import_api.py" = ["ARG"] "services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG002"]
"controllers/console/auth/test_oauth.py" = ["ARG"] "services/recommend_app/test_database_retrieval.py" = ["ARG002"]
"controllers/console/auth/test_password_reset.py" = ["ARG"] "services/test_account_service.py" = ["ARG002"]
"controllers/console/datasets/test_data_source.py" = ["ARG"] "services/test_advanced_prompt_template_service.py" = ["ARG002"]
"controllers/console/test_apikey.py" = ["ARG"] "services/test_app_dsl_service.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"]
"controllers/console/workspace/test_tool_provider.py" = ["ARG"] "services/test_app_service.py" = ["ARG002"]
"controllers/mcp/test_mcp.py" = ["ARG"] "services/test_attachment_service.py" = ["ARG002"]
"controllers/openapi/test_app_dsl.py" = ["ARG"] "services/test_conversation_variable_updater.py" = ["ARG002"]
"controllers/openapi/test_workspaces.py" = ["ARG"] "services/test_dataset_service_batch_update_document_status.py" = ["ARG002"]
"controllers/service_api/dataset/test_dataset.py" = ["ARG"] "services/test_delete_archived_workflow_run.py" = ["ARG002"]
"controllers/web/test_conversation.py" = ["ARG"] "services/test_document_service_rename_document.py" = ["ARG001"]
"controllers/web/test_human_input_form.py" = ["ARG"] "services/test_end_user_service.py" = ["ARG002"]
"controllers/web/test_wraps.py" = ["ARG"] "services/test_feature_service.py" = ["ARG002"]
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG"] "services/test_file_service.py" = ["ARG002"]
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG"] "services/test_file_service_zip_and_lookup.py" = ["TID251"]
"models/test_conversation_message_inputs.py" = ["ARG"] "services/test_messages_clean_service.py" = ["ARG002", "S110"]
"models/test_conversation_status_count.py" = ["ARG"] "services/test_metadata_partial_update.py" = ["ARG002"]
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG"] "services/test_metadata_service.py" = ["ARG002"]
"repositories/test_workflow_run_repository.py" = ["ARG"] "services/test_model_load_balancing_service.py" = ["ARG002"]
"services/auth/test_api_key_auth_service.py" = ["ARG"] "services/test_model_provider_service.py" = ["ARG002"]
"services/auth/test_auth_integration.py" = ["ARG"] "services/test_ops_service.py" = ["ARG002"]
"services/dataset_collection_binding.py" = ["ARG"] "services/test_webapp_auth_service.py" = ["ARG002"]
"services/dataset_service_update_delete.py" = ["ARG"] "services/test_webhook_service.py" = ["ARG002"]
"services/document_service_status.py" = ["ARG"] "services/test_workflow_draft_variable_service.py" = ["ARG002"]
"services/enterprise/test_account_deletion_sync.py" = ["ARG"] "services/test_workflow_run_service.py" = ["ARG002"]
"services/plugin/test_plugin_parameter_service.py" = ["ARG"] "services/test_workflow_service.py" = ["ARG002"]
"services/plugin/test_plugin_service.py" = ["ARG"] "services/test_workspace_service.py" = ["ARG002"]
"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG"] "services/tools/test_api_tools_manage_service.py" = ["ARG002"]
"services/recommend_app/test_database_retrieval.py" = ["ARG"] "services/tools/test_mcp_tools_manage_service.py" = ["ARG002", "ARG005"]
"services/test_account_service.py" = ["ARG"] "services/tools/test_tools_transform_service.py" = ["ARG002"]
"services/test_advanced_prompt_template_service.py" = ["ARG"] "services/workflow/test_workflow_converter.py" = ["ARG002"]
"services/test_annotation_service.py" = ["ARG"] "tasks/test_add_document_to_index_task.py" = ["ARG002"]
"services/test_api_based_extension_service.py" = ["ARG"] "tasks/test_batch_clean_document_task.py" = ["ARG002"]
"services/test_api_token_service.py" = ["ARG"] "tasks/test_batch_create_segment_to_index_task.py" = ["ARG001", "ARG002"]
"services/test_app_generate_service.py" = ["ARG"]
"services/test_app_service.py" = ["ARG"]
"services/test_attachment_service.py" = ["ARG"]
"services/test_conversation_variable_updater.py" = ["ARG"]
"services/test_dataset_permission_service.py" = ["ARG"]
"services/test_dataset_service_batch_update_document_status.py" = ["ARG"]
"services/test_dataset_service_retrieval.py" = ["ARG"]
"services/test_delete_archived_workflow_run.py" = ["ARG"]
"services/test_document_service_rename_document.py" = ["ARG"]
"services/test_end_user_service.py" = ["ARG"]
"services/test_feature_service.py" = ["ARG"]
"services/test_feedback_service.py" = ["ARG"]
"services/test_file_service.py" = ["ARG"]
"services/test_human_input_delivery_test_service.py" = ["ARG"]
"services/test_message_service.py" = ["ARG"]
"services/test_messages_clean_service.py" = ["ARG", "S110"]
"services/test_metadata_partial_update.py" = ["ARG"]
"services/test_metadata_service.py" = ["ARG"]
"services/test_model_load_balancing_service.py" = ["ARG"]
"services/test_model_provider_service.py" = ["ARG"]
"services/test_oauth_server_service.py" = ["ARG"]
"services/test_ops_service.py" = ["ARG"]
"services/test_saved_message_service.py" = ["ARG"]
"services/test_web_conversation_service.py" = ["ARG"]
"services/test_webapp_auth_service.py" = ["ARG"]
"services/test_webhook_service.py" = ["ARG"]
"services/test_workflow_app_service.py" = ["ARG"]
"services/test_workflow_draft_variable_service.py" = ["ARG"]
"services/test_workflow_run_service.py" = ["ARG"]
"services/test_workflow_service.py" = ["ARG"]
"services/test_workspace_service.py" = ["ARG"]
"services/tools/test_api_tools_manage_service.py" = ["ARG"]
"services/tools/test_mcp_tools_manage_service.py" = ["ARG"]
"services/tools/test_tools_transform_service.py" = ["ARG"]
"services/workflow/test_workflow_converter.py" = ["ARG"]
"tasks/test_add_document_to_index_task.py" = ["ARG"]
"tasks/test_batch_clean_document_task.py" = ["ARG"]
"tasks/test_batch_create_segment_to_index_task.py" = ["ARG"]
"tasks/test_clean_dataset_task.py" = ["T201"] "tasks/test_clean_dataset_task.py" = ["T201"]
"tasks/test_clean_notion_document_task.py" = ["ARG"] "tasks/test_clean_notion_document_task.py" = ["ARG002"]
"tasks/test_create_segment_to_index_task.py" = ["ARG"] "tasks/test_create_segment_to_index_task.py" = ["ARG002"]
"tasks/test_dataset_indexing_task.py" = ["ARG"] "tasks/test_dataset_indexing_task.py" = ["ARG002"]
"tasks/test_deal_dataset_vector_index_task.py" = ["ARG"] "tasks/test_deal_dataset_vector_index_task.py" = ["ARG002"]
"tasks/test_delete_segment_from_index_task.py" = ["ARG"] "tasks/test_delete_segment_from_index_task.py" = ["ARG002"]
"tasks/test_disable_segment_from_index_task.py" = ["ARG"] "tasks/test_disable_segment_from_index_task.py" = ["ARG002"]
"tasks/test_disable_segments_from_index_task.py" = ["ARG"] "tasks/test_disable_segments_from_index_task.py" = ["ARG002"]
"tasks/test_document_indexing_sync_task.py" = ["ARG"] "tasks/test_document_indexing_sync_task.py" = ["ARG002"]
"tasks/test_document_indexing_task.py" = ["ARG"] "tasks/test_document_indexing_task.py" = ["ARG002"]
"tasks/test_document_indexing_update_task.py" = ["ARG"] "tasks/test_document_indexing_update_task.py" = ["ARG002"]
"tasks/test_duplicate_document_indexing_task.py" = ["ARG"] "tasks/test_duplicate_document_indexing_task.py" = ["ARG002"]
"tasks/test_enable_segments_to_index_task.py" = ["ARG"] "tasks/test_enable_segments_to_index_task.py" = ["ARG002"]
"tasks/test_mail_change_mail_task.py" = ["ARG"] "tasks/test_mail_change_mail_task.py" = ["ARG002"]
"tasks/test_mail_email_code_login_task.py" = ["ARG"] "tasks/test_mail_email_code_login_task.py" = ["ARG002"]
"tasks/test_mail_human_input_delivery_task.py" = ["ARG"] "tasks/test_mail_human_input_delivery_task.py" = ["ARG001"]
"tasks/test_mail_inner_task.py" = ["ARG"] "tasks/test_mail_inner_task.py" = ["ARG002"]
"tasks/test_mail_invite_member_task.py" = ["ARG"] "tasks/test_mail_invite_member_task.py" = ["ARG002"]
"tasks/test_mail_owner_transfer_task.py" = ["ARG"] "tasks/test_mail_owner_transfer_task.py" = ["ARG002"]
"tasks/test_mail_register_task.py" = ["ARG"] "tasks/test_mail_register_task.py" = ["ARG002"]
"tasks/test_rag_pipeline_run_tasks.py" = ["ARG"] "tasks/test_rag_pipeline_run_tasks.py" = ["ARG002"]
"test_workflow_pause_integration.py" = ["T201"] "test_workflow_pause_integration.py" = ["T201"]
"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG"] "trigger/conftest.py" = ["ANN401", "TID251"]
"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG"] "trigger/test_trigger_e2e.py" = ["ANN401", "ARG001", "TID251"]
"workflow/nodes/code_executor/test_code_python3.py" = ["ARG"] "workflow/nodes/code_executor/test_code_javascript.py" = ["ARG002"]
"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG002"]
"workflow/nodes/code_executor/test_code_python3.py" = ["ARG002"]
"workflow/nodes/code_executor/test_utils.py" = ["T201"] "workflow/nodes/code_executor/test_utils.py" = ["T201"]
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
msg = "Use Pydantic payload/query models instead of reqparse."
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
msg = "Use Pydantic payload/query models instead of reqparse."
[lint.flake8-tidy-imports.banned-api."typing.Any"] [lint.flake8-tidy-imports.banned-api."typing.Any"]
msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead." msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead."
@@ -1,42 +1,24 @@
preset = "strict" preset = "strict"
strict-callable-subtyping = true
project-includes = ["."] project-includes = ["."]
search-path = ["../.."] search-path = ["../.."]
python-platform = "linux"
python-version = "3.12.0"
infer-with-first-use = true
min-severity = "warn"
# Verify project-excludes from the repo root: # Existing strict-mode debt. Remove a file when bringing it under strict checking.
# tmp_config=$(mktemp --tmpdir=api/tests/test_containers_integration_tests pyrefly-no-excludes.XXXXXX.toml)
# awk 'BEGIN {skip=0} /^project-excludes = \[/ {skip=1; next} skip && /^\]/ {skip=0; next} !skip {print}' api/tests/test_containers_integration_tests/pyrefly.toml > "$tmp_config"
# tmp_name=$(basename "$tmp_config")
# comm -3 <(sed -n 's/^ "\(.*\)",$/\1/p' api/tests/test_containers_integration_tests/pyrefly.toml | sort) <(uv --directory api run pyrefly check --config "tests/test_containers_integration_tests/$tmp_name" --summary=none --output-format=min-text 2>/dev/null | rg '^ERROR ' | sed -E 's#^ERROR (tests/test_containers_integration_tests/[^:]+):.*#\1#' | sed 's#^tests/test_containers_integration_tests/##' | sort -u)
# rm --force "$tmp_config"
project-excludes = [ project-excludes = [
"commands/test_legacy_model_type_migration.py", "commands/test_legacy_model_type_migration.py",
"controllers/console/app/test_app_apis.py",
"controllers/console/app/test_app_import_api.py",
"controllers/console/app/test_chat_conversation_status_count_api.py", "controllers/console/app/test_chat_conversation_status_count_api.py",
"controllers/console/app/test_conversation_read_timestamp.py", "controllers/console/app/test_conversation_read_timestamp.py",
"controllers/console/app/test_workflow_draft_variable.py", "controllers/console/app/test_workflow_draft_variable.py",
"controllers/console/auth/test_email_register.py",
"controllers/console/auth/test_forgot_password.py",
"controllers/console/auth/test_oauth.py",
"controllers/console/auth/test_password_reset.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py",
"controllers/console/datasets/test_data_source.py",
"controllers/console/explore/test_conversation.py",
"controllers/console/test_api_based_extension.py", "controllers/console/test_api_based_extension.py",
"controllers/console/test_apikey.py", "controllers/console/test_apikey.py",
"controllers/console/workspace/test_tool_provider.py",
"controllers/console/workspace/test_trigger_providers.py",
"controllers/console/workspace/test_workspace_wraps.py", "controllers/console/workspace/test_workspace_wraps.py",
"controllers/mcp/test_mcp.py",
"controllers/service_api/dataset/test_dataset.py", "controllers/service_api/dataset/test_dataset.py",
"controllers/service_api/test_site.py", "controllers/service_api/test_site.py",
"controllers/web/test_conversation.py", "controllers/web/test_conversation.py",
"controllers/web/test_site.py", "controllers/web/test_site.py",
"controllers/web/test_web_forgot_password.py",
"controllers/web/test_wraps.py", "controllers/web/test_wraps.py",
"core/app/layers/test_pause_state_persist_layer.py", "core/app/layers/test_pause_state_persist_layer.py",
"core/rag/pipeline/test_queue_integration.py", "core/rag/pipeline/test_queue_integration.py",
@@ -57,22 +39,16 @@ project-excludes = [
"repositories/test_sqlalchemy_execution_extra_content_repository.py", "repositories/test_sqlalchemy_execution_extra_content_repository.py",
"repositories/test_sqlalchemy_workflow_node_execution_repository.py", "repositories/test_sqlalchemy_workflow_node_execution_repository.py",
"repositories/test_workflow_run_repository.py", "repositories/test_workflow_run_repository.py",
"services/auth/test_api_key_auth_service.py",
"services/auth/test_auth_integration.py", "services/auth/test_auth_integration.py",
"services/dataset_collection_binding.py", "services/dataset_collection_binding.py",
"services/dataset_service_update_delete.py", "services/dataset_service_update_delete.py",
"services/document_service_status.py", "services/document_service_status.py",
"services/enterprise/test_account_deletion_sync.py",
"services/plugin/test_plugin_parameter_service.py",
"services/plugin/test_plugin_service.py",
"services/rag_pipeline/test_rag_pipeline_service_db.py",
"services/recommend_app/test_database_retrieval.py", "services/recommend_app/test_database_retrieval.py",
"services/test_account_service.py", "services/test_account_service.py",
"services/test_advanced_prompt_template_service.py", "services/test_advanced_prompt_template_service.py",
"services/test_agent_service.py", "services/test_agent_service.py",
"services/test_annotation_service.py", "services/test_annotation_service.py",
"services/test_api_based_extension_service.py", "services/test_api_based_extension_service.py",
"services/test_api_token_service.py",
"services/test_app_dsl_service.py", "services/test_app_dsl_service.py",
"services/test_app_generate_service.py", "services/test_app_generate_service.py",
"services/test_app_service.py", "services/test_app_service.py",
@@ -97,20 +73,15 @@ project-excludes = [
"services/test_document_service_rename_document.py", "services/test_document_service_rename_document.py",
"services/test_end_user_service.py", "services/test_end_user_service.py",
"services/test_feature_service.py", "services/test_feature_service.py",
"services/test_feedback_service.py",
"services/test_file_service.py", "services/test_file_service.py",
"services/test_human_input_delivery_test.py", "services/test_human_input_delivery_test.py",
"services/test_human_input_delivery_test_service.py",
"services/test_message_export_service.py", "services/test_message_export_service.py",
"services/test_message_service.py", "services/test_message_service.py",
"services/test_message_service_execution_extra_content.py", "services/test_message_service_execution_extra_content.py",
"services/test_message_service_extra_contents.py", "services/test_message_service_extra_contents.py",
"services/test_messages_clean_service.py", "services/test_messages_clean_service.py",
"services/test_metadata_partial_update.py",
"services/test_metadata_service.py",
"services/test_model_load_balancing_service.py", "services/test_model_load_balancing_service.py",
"services/test_model_provider_service.py", "services/test_model_provider_service.py",
"services/test_oauth_server_service.py",
"services/test_ops_service.py", "services/test_ops_service.py",
"services/test_restore_archived_workflow_run.py", "services/test_restore_archived_workflow_run.py",
"services/test_saved_message_service.py", "services/test_saved_message_service.py",
@@ -124,7 +95,6 @@ project-excludes = [
"services/test_workflow_run_service.py", "services/test_workflow_run_service.py",
"services/test_workflow_service.py", "services/test_workflow_service.py",
"services/test_workspace_service.py", "services/test_workspace_service.py",
"services/tools/test_api_tools_manage_service.py",
"services/tools/test_mcp_tools_manage_service.py", "services/tools/test_mcp_tools_manage_service.py",
"services/tools/test_tools_transform_service.py", "services/tools/test_tools_transform_service.py",
"services/tools/test_workflow_tools_manage_service.py", "services/tools/test_workflow_tools_manage_service.py",
@@ -161,7 +131,6 @@ project-excludes = [
"test_workflow_pause_integration.py", "test_workflow_pause_integration.py",
"trigger/conftest.py", "trigger/conftest.py",
"trigger/test_trigger_e2e.py", "trigger/test_trigger_e2e.py",
"workflow/nodes/code_executor/test_code_executor.py",
"workflow/nodes/code_executor/test_code_javascript.py", "workflow/nodes/code_executor/test_code_javascript.py",
"workflow/nodes/code_executor/test_code_jinja2.py", "workflow/nodes/code_executor/test_code_jinja2.py",
"workflow/nodes/code_executor/test_code_python3.py", "workflow/nodes/code_executor/test_code_python3.py",
@@ -169,6 +138,7 @@ project-excludes = [
] ]
[errors] [errors]
missing-override-decorator = "error"
redundant-cast = true redundant-cast = true
unannotated-return = true unannotated-return = true
unnecessary-type-conversion = true unnecessary-type-conversion = true
@@ -124,6 +124,7 @@ class TestAppDslService:
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.app_service.FeatureService") as mock_feature_service, patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.agent.home_snapshot_service.AgentHomeSnapshotService._client") as mock_home_snapshot_client,
): ):
mock_workflow_service.return_value.get_draft_workflow.return_value = None mock_workflow_service.return_value.get_draft_workflow.return_value = None
mock_workflow_service.return_value.sync_draft_workflow.return_value = MagicMock() mock_workflow_service.return_value.sync_draft_workflow.return_value = MagicMock()
@@ -142,6 +143,9 @@ class TestAppDslService:
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
mock_home_snapshot_client.return_value.__enter__.return_value.initialize_home_snapshot_sync.side_effect = (
lambda request: SimpleNamespace(snapshot_ref=f"test:{request.home_snapshot_id}")
)
yield { yield {
"workflow_service": mock_workflow_service, "workflow_service": mock_workflow_service,
@@ -1034,7 +1038,9 @@ class TestAppDslService:
) )
) )
imported_graph, warnings = AgentDslService(db_session_with_containers).import_workflow_packages( imported_graph, warnings, retirement_candidates = AgentDslService(
db_session_with_containers
).import_workflow_packages(
workflow=workflow, workflow=workflow,
portable_graph=graph, portable_graph=graph,
raw_packages={"agent_1": package.model_dump(mode="json")}, raw_packages={"agent_1": package.model_dump(mode="json")},
@@ -1043,6 +1049,7 @@ class TestAppDslService:
db_session_with_containers.commit() db_session_with_containers.commit()
assert warnings == [] assert warnings == []
assert retirement_candidates == set()
graph_bindings = [node["data"]["agent_binding"] for node in imported_graph["nodes"]] graph_bindings = [node["data"]["agent_binding"] for node in imported_graph["nodes"]]
assert all(binding["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value for binding in graph_bindings) assert all(binding["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value for binding in graph_bindings)
assert len({binding["agent_id"] for binding in graph_bindings}) == 2 assert len({binding["agent_id"] for binding in graph_bindings}) == 2
@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from types import SimpleNamespace
from unittest.mock import create_autospec, patch from unittest.mock import create_autospec, patch
import pytest import pytest
@@ -29,6 +30,7 @@ class TestAppService:
patch("services.app_service.EnterpriseService") as mock_enterprise_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service, patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.agent.home_snapshot_service.AgentHomeSnapshotService._client") as mock_home_snapshot_client,
): ):
# Setup default mock returns for app service # Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
@@ -42,6 +44,9 @@ class TestAppService:
mock_model_instance = mock_model_manager.return_value mock_model_instance = mock_model_manager.return_value
mock_model_instance.get_default_model_instance.return_value = None mock_model_instance.get_default_model_instance.return_value = None
mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo") mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo")
mock_home_snapshot_client.return_value.__enter__.return_value.initialize_home_snapshot_sync.side_effect = (
lambda request: SimpleNamespace(snapshot_ref=f"test:{request.home_snapshot_id}")
)
yield { yield {
"feature_service": mock_feature_service, "feature_service": mock_feature_service,
@@ -6,13 +6,11 @@ from unittest.mock import patch
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from agenton.compositor import CompositorSessionSnapshot
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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 core.app.entities.app_invoke_entities import InvokeFrom
from models import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus, TenantAccountRole from models import TenantAccountRole
from models.account import Account, Tenant, TenantAccountJoin from models.account import Account, Tenant, TenantAccountJoin
from models.enums import ConversationFromSource, EndUserType from models.enums import ConversationFromSource, EndUserType
from models.model import App, Conversation, EndUser, Message, MessageAnnotation from models.model import App, Conversation, EndUser, Message, MessageAnnotation
@@ -1077,9 +1075,8 @@ class TestConversationServiceExport:
# Assert # Assert
assert result == conversation assert result == conversation
@patch("services.conversation_service.cleanup_conversation_agent_runtime_session")
@patch("services.conversation_service.delete_conversation_related_data") @patch("services.conversation_service.delete_conversation_related_data")
def test_delete_conversation(self, mock_delete_task, mock_cleanup_task, db_session_with_containers: Session): def test_delete_conversation(self, mock_delete_task, db_session_with_containers: Session):
""" """
Test conversation deletion with async cleanup. Test conversation deletion with async cleanup.
@@ -1098,20 +1095,6 @@ class TestConversationServiceExport:
user, user,
) )
conversation_id = conversation.id 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 # Act - Delete the conversation
ConversationService.delete( ConversationService.delete(
@@ -1126,27 +1109,11 @@ class TestConversationServiceExport:
# Step 2: Async cleanup task triggered # Step 2: Async cleanup task triggered
# The Celery task will handle cleanup of messages, annotations, etc. # The Celery task will handle cleanup of messages, annotations, etc.
mock_delete_task.delay.assert_called_once_with(conversation_id) 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") @patch("services.conversation_service.delete_conversation_related_data")
def test_delete_conversation_not_owned_by_account( def test_delete_conversation_not_owned_by_account(
self, self,
mock_delete_task, mock_delete_task,
mock_cleanup_task,
db_session_with_containers: Session, db_session_with_containers: Session,
): ):
""" """
@@ -1178,22 +1145,17 @@ class TestConversationServiceExport:
not_deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation.id)) not_deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation.id))
assert not_deleted is not None assert not_deleted is not None
mock_delete_task.delay.assert_not_called() 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") @patch("services.conversation_service.delete_conversation_related_data")
def test_delete_handles_exception_and_rollback( def test_delete_handles_exception_and_rollback(
self, self,
mock_delete_task, mock_delete_task,
mock_cleanup_task,
db_session_with_containers: Session, db_session_with_containers: Session,
): ):
""" """
Test that delete propagates exceptions and does not trigger the cleanup task. Test that delete propagates exceptions and does not trigger the cleanup task.
When a DB error occurs during deletion, the conversation row stays in When a DB error occurs during deletion, the conversation row stays in place.
place, but any already-enqueued Agent backend cleanup remains a
best-effort terminal lifecycle action.
""" """
# Arrange # Arrange
app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account( app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account(
@@ -1203,20 +1165,6 @@ class TestConversationServiceExport:
db_session_with_containers, app_model, user db_session_with_containers, app_model, user
) )
conversation_id = conversation.id 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 # 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")): with patch.object(db_session_with_containers, "delete", side_effect=Exception("DB error")):
@@ -1228,111 +1176,9 @@ class TestConversationServiceExport:
session=db_session_with_containers, session=db_session_with_containers,
) )
# Assert — related-data deletion is not scheduled, but the backend # Assert — related-data deletion is not scheduled.
# cleanup task was already enqueued before the row delete failed.
mock_delete_task.delay.assert_not_called() 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 # Conversation is still present because the deletion was never committed
still_there = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation_id)) still_there = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation_id))
assert still_there is not None 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
@@ -870,12 +870,13 @@ class TestWorkflowService:
from unittest.mock import patch from unittest.mock import patch
with patch("flask_login.utils._get_user", return_value=account, autospec=True): with patch("flask_login.utils._get_user", return_value=account, autospec=True):
result = workflow_service.publish_workflow( result, retirement_candidates = workflow_service.publish_workflow(
session=db_session_with_containers, app_model=app, account=account session=db_session_with_containers, app_model=app, account=account
) )
# Assert # Assert
assert result is not None assert result is not None
assert retirement_candidates == set()
assert result.version != Workflow.VERSION_DRAFT assert result.version != Workflow.VERSION_DRAFT
# Version should be a timestamp format like '2025-08-22 00:10:24.722051' # Version should be a timestamp format like '2025-08-22 00:10:24.722051'
assert isinstance(result.version, str) assert isinstance(result.version, str)
+430
View File
@@ -0,0 +1,430 @@
extend = "../../.ruff.toml"
src = ["../.."]
[lint]
extend-select = ["ANN401", "ARG"]
# Existing strict-mode debt. Remove a file entry when bringing it under strict checking.
[lint.per-file-ignores]
"clients/agent_backend/test_request_builder.py" = ["TID251"]
"commands/test_archive_workflow_runs.py" = ["ARG005"]
"commands/test_data_migration_wizard.py" = ["ARG005"]
"commands/test_legacy_model_type_migration.py" = ["ARG001", "ARG002", "ARG005"]
"controllers/common/test_agent_app_parameters.py" = ["ARG005", "TID251"]
"controllers/common/test_app_access.py" = ["ARG005"]
"controllers/console/agent/test_agent_controllers.py" = ["ARG001", "ARG002", "ARG003", "ARG005", "TID251"]
"controllers/console/app/test_agent_app_sandbox.py" = ["ARG002", "ARG005"]
"controllers/console/app/test_agent_config_inspector.py" = ["ARG005"]
"controllers/console/app/test_agent_drive_inspector.py" = ["ARG005"]
"controllers/console/app/test_agent_manage_guard.py" = ["ARG001"]
"controllers/console/app/test_agent_skills.py" = ["ARG005"]
"controllers/console/app/test_annotation_security.py" = ["ARG002"]
"controllers/console/app/test_app_apis.py" = ["ARG001", "ARG002"]
"controllers/console/app/test_app_import_api.py" = ["ARG001", "ARG002", "ARG005"]
"controllers/console/app/test_app_response_models.py" = ["ARG002", "ARG004", "ARG005"]
"controllers/console/app/test_conversation_api.py" = ["ARG001"]
"controllers/console/app/test_generator_api.py" = ["ARG001"]
"controllers/console/app/test_mcp_server_response.py" = ["ARG002"]
"controllers/console/app/test_message_api.py" = ["ARG001"]
"controllers/console/app/test_statistic_api.py" = ["ANN401", "ARG001", "TID251"]
"controllers/console/app/test_workflow.py" = ["ARG001", "ARG005"]
"controllers/console/app/test_workflow_convert_api.py" = ["ARG005"]
"controllers/console/app/test_workflow_node_output_inspector.py" = ["ANN401", "ARG001", "TID251"]
"controllers/console/app/test_workflow_run_api.py" = ["ANN401", "TID251"]
"controllers/console/app/workflow_draft_variables_test.py" = ["TID251"]
"controllers/console/auth/test_account_activation.py" = ["ARG002"]
"controllers/console/auth/test_authentication_security.py" = ["ARG002"]
"controllers/console/auth/test_email_verification.py" = ["ARG002"]
"controllers/console/auth/test_login_logout.py" = ["ARG002"]
"controllers/console/auth/test_oauth.py" = ["ARG002"]
"controllers/console/auth/test_oauth_timezone.py" = ["ARG001"]
"controllers/console/auth/test_password_reset.py" = ["ARG002"]
"controllers/console/auth/test_token_refresh.py" = ["ARG002"]
"controllers/console/billing/test_billing.py" = ["ARG002"]
"controllers/console/datasets/test_datasets.py" = ["ARG005"]
"controllers/console/datasets/test_datasets_document.py" = ["ARG002", "ARG005"]
"controllers/console/datasets/test_datasets_document_download.py" = ["ARG005"]
"controllers/console/datasets/test_datasets_segments.py" = ["TID251"]
"controllers/console/datasets/test_external.py" = ["TID251"]
"controllers/console/datasets/test_wraps.py" = ["ARG001"]
"controllers/console/explore/test_trial.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
"controllers/console/explore/test_wraps.py" = ["ARG001"]
"controllers/console/snippets/test_snippet_workflow.py" = ["ARG001"]
"controllers/console/tag/test_tags.py" = ["ARG002"]
"controllers/console/test_files.py" = ["ARG001", "ARG002"]
"controllers/console/test_human_input_form.py" = ["ARG001", "ARG005"]
"controllers/console/test_init_validate.py" = ["ARG005"]
"controllers/console/test_workspace_account.py" = ["ARG002"]
"controllers/console/test_workspace_members.py" = ["ARG002", "ARG005"]
"controllers/console/test_wraps.py" = ["ARG001", "ARG002"]
"controllers/console/workspace/test_load_balancing_config.py" = ["ARG001"]
"controllers/console/workspace/test_plugin.py" = ["ARG002", "TID251"]
"controllers/console/workspace/test_snippets.py" = ["ARG002"]
"controllers/console/workspace/test_tool_providers.py" = ["ARG001", "ARG005"]
"controllers/console/workspace/test_trigger_providers.py" = ["ARG001"]
"controllers/console/workspace/test_workspace.py" = ["ARG005"]
"controllers/files/test_image_preview.py" = ["ARG005"]
"controllers/files/test_tool_files.py" = ["ARG002", "ARG005"]
"controllers/files/test_upload.py" = ["ARG002", "ARG005"]
"controllers/inner_api/plugin/test_plugin.py" = ["ARG002"]
"controllers/inner_api/plugin/test_plugin_wraps.py" = ["ARG001", "ARG002", "ARG003", "TID251"]
"controllers/inner_api/test_runtime_credentials.py" = ["ARG001"]
"controllers/mcp/test_mcp.py" = ["ARG002"]
"controllers/openapi/auth/test_conditions.py" = ["ARG005"]
"controllers/openapi/auth/test_flow.py" = ["ARG005"]
"controllers/openapi/auth/test_pipeline.py" = ["ARG001"]
"controllers/openapi/conftest.py" = ["ARG001"]
"controllers/openapi/test_account.py" = ["ARG005"]
"controllers/openapi/test_app_describe_builder.py" = ["ARG001"]
"controllers/openapi/test_app_run_streaming.py" = ["ARG001"]
"controllers/openapi/test_contract.py" = ["ARG001", "TID251"]
"controllers/openapi/test_error_contract.py" = ["ARG002"]
"controllers/openapi/test_human_input_form.py" = ["ARG002"]
"controllers/openapi/test_oauth_sso_claims.py" = ["ARG002"]
"controllers/openapi/test_workflow_events_openapi.py" = ["ARG002", "ARG005"]
"controllers/openapi/test_workspaces_members.py" = ["ARG001"]
"controllers/service_api/app/test_app.py" = ["ARG002"]
"controllers/service_api/app/test_completion.py" = ["ARG002"]
"controllers/service_api/app/test_file.py" = ["ARG002"]
"controllers/service_api/app/test_hitl_service_api.py" = ["ARG002", "ARG005"]
"controllers/service_api/app/test_workflow_events.py" = ["ARG005"]
"controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py" = ["ARG002"]
"controllers/service_api/dataset/test_dataset_segment.py" = ["ARG002"]
"controllers/service_api/dataset/test_document.py" = ["ARG001", "ARG002"]
"controllers/service_api/dataset/test_metadata.py" = ["ARG002"]
"controllers/service_api/test_trace_session_id_parsing.py" = ["ARG001"]
"controllers/service_api/test_wraps.py" = ["ARG001", "ARG002"]
"controllers/trigger/test_trigger.py" = ["ARG002"]
"controllers/trigger/test_webhook.py" = ["ARG002"]
"controllers/web/conftest.py" = ["ANN401", "TID251"]
"controllers/web/test_app.py" = ["ARG002", "ARG005"]
"controllers/web/test_audio.py" = ["ARG002"]
"controllers/web/test_completion.py" = ["ARG002"]
"controllers/web/test_feature.py" = ["ARG002"]
"controllers/web/test_human_input_form.py" = ["ARG001", "ARG002", "ARG005"]
"controllers/web/test_message_endpoints.py" = ["ARG002"]
"controllers/web/test_remote_files.py" = ["ARG002"]
"controllers/web/test_saved_message.py" = ["ARG002"]
"controllers/web/test_web_login.py" = ["ARG002"]
"controllers/web/test_web_passport.py" = ["ARG002"]
"controllers/web/test_workflow.py" = ["ARG002"]
"core/agent/test_base_agent_runner.py" = ["ARG002"]
"core/agent/test_cot_agent_runner.py" = ["ARG001"]
"core/agent/test_cot_chat_agent_runner.py" = ["ARG002"]
"core/agent/test_fc_agent_runner.py" = ["TID251"]
"core/app/app_config/common/test_parameters_mapping.py" = ["ARG002"]
"core/app/app_config/easy_ui_based_app/test_dataset_manager.py" = ["ARG001", "ARG002"]
"core/app/app_config/easy_ui_based_app/test_model_config_converter.py" = ["ARG002"]
"core/app/app_config/easy_ui_based_app/test_variables_manager.py" = ["ARG002"]
"core/app/apps/advanced_chat/test_app_generator.py" = ["ARG001", "ARG002", "ARG005"]
"core/app/apps/advanced_chat/test_app_runner_input_moderation.py" = ["ARG001", "ARG005"]
"core/app/apps/advanced_chat/test_generate_task_pipeline.py" = ["ARG005"]
"core/app/apps/advanced_chat/test_generate_task_pipeline_core.py" = ["ARG001", "ARG002", "ARG005"]
"core/app/apps/agent_app/test_app_generator.py" = ["ARG001", "ARG005"]
"core/app/apps/agent_app/test_app_runner.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
"core/app/apps/agent_app/test_input_guards.py" = ["ANN401", "ARG002", "TID251"]
"core/app/apps/agent_app/test_resolve_agent.py" = ["ANN401", "TID251"]
"core/app/apps/agent_app/test_runtime_request_builder.py" = ["ARG002", "TID251"]
"core/app/apps/agent_chat/test_agent_chat_app_config_manager.py" = ["ARG005"]
"core/app/apps/agent_chat/test_agent_chat_app_generator.py" = ["ARG001"]
"core/app/apps/chat/test_app_config_manager.py" = ["ARG001"]
"core/app/apps/chat/test_app_generator_and_runner.py" = ["ARG001", "ARG002", "ARG005"]
"core/app/apps/common/test_workflow_response_converter_truncation.py" = ["TID251"]
"core/app/apps/completion/test_app_runner.py" = ["ARG001", "ARG002", "ARG005"]
"core/app/apps/pipeline/test_pipeline_generator.py" = ["ARG001", "ARG002", "ARG005"]
"core/app/apps/pipeline/test_pipeline_runner.py" = ["ARG001", "ARG002"]
"core/app/apps/test_advanced_chat_app_generator.py" = ["ARG001"]
"core/app/apps/test_base_app_generator.py" = ["ARG005"]
"core/app/apps/test_base_app_runner.py" = ["ARG001", "ARG002", "ARG005"]
"core/app/apps/test_pause_resume.py" = ["ANN401", "TID251"]
"core/app/apps/test_streaming_utils.py" = ["ARG001"]
"core/app/apps/test_workflow_app_generator.py" = ["ARG005"]
"core/app/apps/test_workflow_app_runner_core.py" = ["ARG001", "ARG002", "ARG004", "ARG005"]
"core/app/apps/test_workflow_app_runner_single_node.py" = ["ANN401", "TID251"]
"core/app/apps/test_workflow_pause_events.py" = ["ARG005"]
"core/app/apps/workflow/test_app_generator_extra.py" = ["ARG005"]
"core/app/apps/workflow/test_generate_task_pipeline_core.py" = ["ARG002", "ARG005"]
"core/app/features/rate_limiting/test_rate_limit.py" = ["ARG001"]
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py" = ["ARG002"]
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py" = ["ARG001", "ARG002", "ARG005"]
"core/app/task_pipeline/test_message_cycle_manager_optimization.py" = ["ARG002"]
"core/app/test_easy_ui_model_config_manager.py" = ["ARG005"]
"core/app/workflow/layers/test_persistence_inspector_publish.py" = ["ANN401", "ARG005", "TID251"]
"core/app/workflow/test_file_runtime.py" = ["ARG001", "ARG005"]
"core/app/workflow/test_observability_layer_extra.py" = ["ARG005"]
"core/app/workflow/test_persistence_layer.py" = ["ARG001"]
"core/base/test_app_generator_tts_publisher.py" = ["ARG002"]
"core/callback_handler/test_agent_tool_callback_handler.py" = ["ARG002"]
"core/callback_handler/test_workflow_tool_callback_handler.py" = ["ARG002"]
"core/datasource/__base/test_datasource_provider.py" = ["ARG002"]
"core/datasource/test_datasource_file_manager.py" = ["ARG001", "ARG002"]
"core/datasource/test_notion_provider.py" = ["ARG002", "TID251"]
"core/datasource/test_website_crawl.py" = ["ARG002"]
"core/datasource/utils/test_message_transformer.py" = ["ARG002"]
"core/entities/test_entities_mcp_provider.py" = ["ARG001"]
"core/entities/test_entities_provider_configuration.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
"core/extension/test_extensible.py" = ["ARG002", "ARG005"]
"core/external_data_tool/api/test_api.py" = ["ARG001"]
"core/external_data_tool/test_base.py" = ["TID251"]
"core/external_data_tool/test_external_data_fetch.py" = ["ARG001"]
"core/helper/code_executor/test_code_executor.py" = ["TID251"]
"core/helper/code_executor/test_template_transformer.py" = ["ANN401", "TID251"]
"core/llm_generator/test_llm_generator.py" = ["ARG002"]
"core/mcp/auth/test_auth_flow.py" = ["ARG002"]
"core/mcp/client/test_session.py" = ["ARG001", "TID251"]
"core/mcp/client/test_sse.py" = ["ARG001", "TID251"]
"core/mcp/client/test_streamable_http.py" = ["ARG001", "ARG005", "S110", "TID251"]
"core/mcp/session/test_base_session.py" = ["S110"]
"core/mcp/session/test_client_session.py" = ["ARG005"]
"core/mcp/test_mcp_client.py" = ["ARG002"]
"core/memory/test_token_buffer_memory.py" = ["ARG002"]
"core/moderation/test_content_moderation.py" = ["TID251"]
"core/moderation/test_output_moderation.py" = ["ARG001", "ARG002"]
"core/ops/test_base_trace_instance.py" = ["ARG001"]
"core/ops/test_lookup_helpers.py" = ["ARG002"]
"core/ops/test_ops_trace_manager.py" = ["ARG001", "ARG002", "ARG005"]
"core/ops/test_trace_queue_manager.py" = ["ARG004"]
"core/ops/test_trace_session_metadata.py" = ["ARG001", "ARG005"]
"core/plugin/impl/test_agent_client.py" = ["ARG001"]
"core/plugin/impl/test_datasource_manager.py" = ["ARG001"]
"core/plugin/impl/test_oauth_handler.py" = ["ARG001"]
"core/plugin/impl/test_tool_manager.py" = ["ARG001"]
"core/plugin/impl/test_trigger_client.py" = ["ARG001"]
"core/plugin/test_endpoint_client.py" = ["ARG002"]
"core/plugin/test_model_runtime_adapter.py" = ["ARG002"]
"core/plugin/test_plugin_runtime.py" = ["ARG001", "ARG002", "TID251"]
"core/prompt/test_advanced_prompt_transform.py" = ["ARG005"]
"core/prompt/test_prompt_transform.py" = ["ARG005"]
"core/rag/datasource/keyword/jieba/test_jieba.py" = ["ARG001", "TID251"]
"core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py" = ["ARG004"]
"core/rag/datasource/keyword/test_keyword_factory.py" = ["ARG005"]
"core/rag/datasource/test_datasource_retrieval.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
"core/rag/datasource/test_retrieval_attachment_access.py" = ["ARG005"]
"core/rag/datasource/vdb/test_vector_factory.py" = ["ARG002"]
"core/rag/embedding/test_embedding_base.py" = ["TID251"]
"core/rag/embedding/test_embedding_service.py" = ["ARG001"]
"core/rag/extractor/firecrawl/test_firecrawl.py" = ["TID251"]
"core/rag/extractor/test_csv_extractor.py" = ["ARG001", "ARG005"]
"core/rag/extractor/test_excel_extractor.py" = ["ARG002", "ARG005"]
"core/rag/extractor/test_extract_processor.py" = ["ARG001", "ARG005"]
"core/rag/extractor/test_helpers.py" = ["ARG002"]
"core/rag/extractor/test_markdown_extractor.py" = ["ARG001"]
"core/rag/extractor/test_notion_extractor.py" = ["ARG002", "ARG005"]
"core/rag/extractor/test_pdf_extractor.py" = ["ARG001", "ARG005"]
"core/rag/extractor/test_text_extractor.py" = ["ARG001"]
"core/rag/extractor/test_word_extractor.py" = ["ARG001", "ARG002", "ARG005"]
"core/rag/extractor/unstructured/test_unstructured_extractors.py" = ["ARG001", "ARG005"]
"core/rag/extractor/watercrawl/test_watercrawl.py" = ["ARG001", "ARG005", "TID251"]
"core/rag/indexing/processor/conftest.py" = ["ANN401", "ARG002", "TID251"]
"core/rag/indexing/processor/test_paragraph_index_processor.py" = ["ARG002", "TID251"]
"core/rag/indexing/processor/test_qa_index_processor.py" = ["ARG001", "TID251"]
"core/rag/indexing/test_index_processor.py" = ["ARG005"]
"core/rag/indexing/test_indexing_runner.py" = ["ARG005", "TID251"]
"core/rag/pipeline/test_queue.py" = ["ARG002"]
"core/rag/retrieval/test_dataset_retrieval.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
"core/rag/splitter/test_text_splitter.py" = ["ARG002", "ARG005"]
"core/repositories/test_celery_workflow_execution_repository.py" = ["ARG002"]
"core/repositories/test_celery_workflow_node_execution_repository.py" = ["ARG002"]
"core/repositories/test_human_input_form_repository_impl.py" = ["ARG001", "ARG005"]
"core/repositories/test_human_input_repository.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
"core/repositories/test_sqlalchemy_workflow_node_execution_repository.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
"core/repositories/test_workflow_node_execution_truncation.py" = ["TID251"]
"core/schemas/test_resolver.py" = ["ARG005", "T201"]
"core/telemetry/test_facade.py" = ["ARG002", "ARG004"]
"core/telemetry/test_gateway_integration.py" = ["ARG002"]
"core/test_model_manager.py" = ["ARG001"]
"core/test_trigger_debug_event_selectors.py" = ["ARG002"]
"core/tools/test_base_tool.py" = ["ANN401", "ARG002", "TID251"]
"core/tools/test_builtin_tool_base.py" = ["ARG001", "ARG002", "TID251"]
"core/tools/test_builtin_tool_provider.py" = ["ARG001", "ARG005", "TID251"]
"core/tools/test_builtin_tools_extra.py" = ["ARG005"]
"core/tools/test_custom_tool.py" = ["ARG001", "ARG005", "TID251"]
"core/tools/test_dataset_retriever_tool.py" = ["ARG005"]
"core/tools/test_mcp_tool.py" = ["S110"]
"core/tools/test_tool_engine.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
"core/tools/test_tool_file_manager.py" = ["ARG001"]
"core/tools/test_tool_label_manager.py" = ["TID251"]
"core/tools/test_tool_manager.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
"core/tools/test_tool_provider_controller.py" = ["TID251"]
"core/tools/utils/test_configuration.py" = ["ARG001", "ARG002", "TID251"]
"core/tools/utils/test_encryption.py" = ["ANN401", "TID251"]
"core/tools/utils/test_message_transformer.py" = ["TID251"]
"core/tools/utils/test_misc_utils_extra.py" = ["ARG002"]
"core/tools/utils/test_model_invocation_utils.py" = ["ARG005", "TID251"]
"core/tools/utils/test_parser.py" = ["TID251"]
"core/tools/utils/test_web_reader_tool.py" = ["ARG001", "ARG002", "ARG005"]
"core/tools/workflow_as_tool/test_provider.py" = ["TID251"]
"core/tools/workflow_as_tool/test_tool.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
"core/trigger/conftest.py" = ["ANN401", "TID251"]
"core/trigger/debug/test_debug_event_selectors.py" = ["ARG002", "TID251"]
"core/variables/test_segment_type_validation.py" = ["TID251"]
"core/workflow/context/test_execution_context.py" = ["ANN401", "ARG002", "S110", "TID251"]
"core/workflow/context/test_flask_app_context.py" = ["ARG002"]
"core/workflow/generator/test_runner.py" = ["ARG001", "ARG002", "TID251"]
"core/workflow/generator/test_runner_missing.py" = ["ARG003"]
"core/workflow/generator/test_tool_catalogue.py" = ["ARG002"]
"core/workflow/graph_engine/layers/test_observability.py" = ["ARG002"]
"core/workflow/graph_engine/test_mock_config.py" = ["TID251"]
"core/workflow/graph_engine/test_mock_factory.py" = ["TID251"]
"core/workflow/graph_engine/test_mock_nodes.py" = ["ANN401", "S110", "TID251"]
"core/workflow/graph_engine/test_parallel_human_input_join_resume.py" = ["ARG002", "TID251"]
"core/workflow/graph_engine/test_table_runner.py" = ["ARG001", "TID251"]
"core/workflow/nodes/agent_v2/test_agent_node.py" = ["ARG001", "ARG002", "ARG005"]
"core/workflow/nodes/agent_v2/test_ask_human_hitl.py" = ["ANN401", "TID251"]
"core/workflow/nodes/agent_v2/test_dify_tools_builder.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"]
"core/workflow/nodes/agent_v2/test_output_adapter.py" = ["ARG005"]
"core/workflow/nodes/agent_v2/test_runtime_request_builder.py" = ["ARG002"]
"core/workflow/nodes/agent_v2/test_validators.py" = ["ARG001"]
"core/workflow/nodes/http_request/test_http_request_node.py" = ["ANN401", "ARG002", "TID251"]
"core/workflow/nodes/human_input/test_entities.py" = ["TID251"]
"core/workflow/nodes/human_input/test_human_input_form_filled_event.py" = ["TID251"]
"core/workflow/nodes/iteration/test_iteration_child_engine_errors.py" = ["ARG002", "TID251"]
"core/workflow/nodes/knowledge_index/test_knowledge_index_node.py" = ["ARG002"]
"core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py" = ["ARG002"]
"core/workflow/nodes/llm/test_node.py" = ["ARG002"]
"core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py" = ["TID251"]
"core/workflow/nodes/test_document_extractor_node.py" = ["ARG001"]
"core/workflow/nodes/tool/test_tool_node.py" = ["ANN401", "ARG002", "TID251"]
"core/workflow/nodes/webhook/test_webhook_file_conversion.py" = ["TID251"]
"core/workflow/nodes/webhook/test_webhook_node.py" = ["TID251"]
"core/workflow/test_form_input_serialization_compat.py" = ["ANN401", "TID251"]
"core/workflow/test_human_input_adapter.py" = ["ARG005"]
"core/workflow/test_node_factory.py" = ["ARG002"]
"core/workflow/test_workflow_entry.py" = ["ARG001"]
"enterprise/telemetry/test_enterprise_trace.py" = ["ARG002", "TID251"]
"enterprise/telemetry/test_exporter.py" = ["ARG001"]
"enterprise/telemetry/test_gateway.py" = ["ARG002"]
"events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py" = ["ARG005"]
"extensions/logstore/test_sql_escape.py" = ["ARG001", "ARG002"]
"extensions/otel/decorators/handlers/test_generate_handler.py" = ["ARG001", "ARG002"]
"extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py" = ["ARG001"]
"extensions/otel/decorators/test_base.py" = ["ARG002"]
"extensions/otel/decorators/test_handler.py" = ["ARG002"]
"extensions/otel/test_retrieval_tracing.py" = ["ARG001"]
"extensions/test_ext_request_logging.py" = ["ARG002"]
"extensions/test_redis.py" = ["ARG001"]
"factories/test_build_from_mapping.py" = ["ARG001"]
"factories/test_file_factory.py" = ["ARG001"]
"factories/test_variable_factory.py" = ["TID251"]
"fields/test_file_fields.py" = ["ARG005"]
"libs/_human_input/support.py" = ["TID251"]
"libs/broadcast_channel/redis/test_channel_unit_tests.py" = ["ARG002", "ARG005"]
"libs/broadcast_channel/redis/test_streams_channel_unit_tests.py" = ["ARG001", "ARG002", "TID251"]
"libs/test_cron_compatibility.py" = ["S110"]
"libs/test_email_i18n.py" = ["ANN401", "TID251"]
"libs/test_oauth_bearer_rate_limit_ordering.py" = ["ARG001"]
"libs/test_pyrefly_type_coverage.py" = ["TID251"]
"libs/test_schedule_utils_enhanced.py" = ["S110"]
"libs/test_sendgrid_client.py" = ["ARG001", "TID251"]
"libs/test_smtp_client.py" = ["TID251"]
"models/test_dataset_models.py" = ["ARG005"]
"models/test_plugin_entities.py" = ["TID251"]
"models/test_snippet.py" = ["ARG001"]
"oss/__mock/aliyun_oss.py" = ["ARG002"]
"oss/__mock/baidu_obs.py" = ["ARG002"]
"oss/__mock/base.py" = ["ARG002"]
"oss/__mock/tencent_cos.py" = ["ARG002"]
"oss/__mock/volcengine_tos.py" = ["ARG002"]
"oss/aliyun_oss/aliyun_oss/test_aliyun_oss.py" = ["ARG002"]
"oss/baidu_obs/test_baidu_obs.py" = ["ARG002"]
"oss/opendal/test_opendal.py" = ["ARG002"]
"oss/tencent_cos/test_tencent_cos.py" = ["ARG002"]
"oss/volcengine_tos/test_volcengine_tos.py" = ["ARG002"]
"services/agent/test_agent_observability_service.py" = ["ARG002", "ARG005"]
"services/agent/test_agent_services.py" = ["ARG001", "ARG002", "ARG003", "ARG005"]
"services/agent/test_composer_candidates.py" = ["ARG005"]
"services/agent/test_prompt_mentions.py" = ["ARG005"]
"services/agent/test_skill_tool_inference_service.py" = ["ARG001", "ARG005"]
"services/auth/test_jina_auth_standalone_module.py" = ["TID251"]
"services/controller_api.py" = ["ARG002"]
"services/data_migration/test_import_service.py" = ["ARG002", "ARG005"]
"services/dataset_service_test_helpers.py" = ["TID251"]
"services/enterprise/test_account_deletion_sync.py" = ["ARG001"]
"services/enterprise/test_rbac_service.py" = ["ARG002"]
"services/enterprise/test_traceparent_propagation.py" = ["ARG002"]
"services/hit_service.py" = ["TID251"]
"services/plugin/test_plugin_parameter_service.py" = ["ARG002"]
"services/rag_pipeline/pipeline_template/test_built_in_retrieval.py" = ["ARG001"]
"services/rag_pipeline/test_rag_pipeline_dsl_service.py" = ["ARG001", "ARG005", "T201", "TID251"]
"services/rag_pipeline/test_rag_pipeline_service.py" = ["ARG001", "ARG005"]
"services/rag_pipeline/test_rag_pipeline_task_proxy.py" = ["ARG001", "ARG005"]
"services/rag_pipeline/test_rag_pipeline_transform_service.py" = ["ARG001"]
"services/recommend_app/test_remote_retrieval.py" = ["ARG002"]
"services/retention/workflow_run/test_archive_download_preparation.py" = ["ARG002"]
"services/retention/workflow_run/test_archive_log_service.py" = ["ARG001", "ARG002"]
"services/retention/workflow_run/test_bundle_archive_maintenance.py" = ["TID251"]
"services/retention/workflow_run/test_restore_archived_workflow_run.py" = ["ARG002"]
"services/test_account_service.py" = ["ARG001", "ARG002"]
"services/test_annotation_service.py" = ["ANN401", "TID251"]
"services/test_api_token_service.py" = ["ARG002"]
"services/test_app_generate_service.py" = ["ARG001", "ARG002", "ARG004"]
"services/test_app_generate_service_streaming_integration.py" = ["ARG002", "TID251"]
"services/test_archive_workflow_run_logs.py" = ["ARG002"]
"services/test_audio_service.py" = ["ARG002", "TID251"]
"services/test_batch_indexing_base.py" = ["ANN401", "TID251"]
"services/test_billing_service.py" = ["ARG001", "ARG002"]
"services/test_clear_free_plan_expired_workflow_run_logs.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
"services/test_clear_free_plan_tenant_expired_logs.py" = ["ARG002", "ARG003"]
"services/test_dataset_service_document.py" = ["ARG002"]
"services/test_dataset_service_lock_not_owned.py" = ["ARG001", "ARG005"]
"services/test_dataset_service_segment.py" = ["ARG002"]
"services/test_datasource_provider_service.py" = ["ARG002"]
"services/test_external_dataset_service.py" = ["ARG002", "TID251"]
"services/test_feature_service_human_input_email_delivery.py" = ["ARG005"]
"services/test_feedback_service.py" = ["ARG002"]
"services/test_human_input_delivery_test_service.py" = ["ARG005"]
"services/test_knowledge_service.py" = ["TID251"]
"services/test_message_service.py" = ["ARG002"]
"services/test_messages_clean_service.py" = ["TID251"]
"services/test_model_load_balancing_service.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
"services/test_model_provider_service.py" = ["ANN401", "TID251"]
"services/test_model_provider_service_sanitization.py" = ["ARG002", "ARG005"]
"services/test_oauth_server_service.py" = ["ARG002"]
"services/test_operation_service.py" = ["TID251"]
"services/test_rag_pipeline_task_proxy.py" = ["ARG002"]
"services/test_recommended_app_service.py" = ["ARG001"]
"services/test_schedule_service.py" = ["ANN401", "TID251"]
"services/test_snippet_service.py" = ["ARG001", "ARG002"]
"services/test_summary_index_service.py" = ["ARG001"]
"services/test_telemetry_service.py" = ["ARG001", "ARG005"]
"services/test_variable_truncator.py" = ["ARG002", "TID251"]
"services/test_variable_truncator_additional.py" = ["ANN401", "TID251"]
"services/test_vector_service.py" = ["ARG001", "TID251"]
"services/test_webhook_service_additional.py" = ["ANN401", "ARG002", "TID251"]
"services/test_website_service.py" = ["TID251"]
"services/test_workflow_comment_service.py" = ["ARG001", "ARG002"]
"services/test_workflow_run_service.py" = ["ANN401", "ARG002", "TID251"]
"services/test_workflow_service.py" = ["ANN401", "ARG002", "TID251"]
"services/tools/test_builtin_tools_manage_service.py" = ["ARG001", "ARG002"]
"services/tools/test_tools_manage_service.py" = ["ARG002"]
"services/workflow/test_inspector_events.py" = ["ANN401", "TID251"]
"services/workflow/test_node_output_inspector_service.py" = ["TID251"]
"services/workflow/test_workflow_converter_additional.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
"services/workflow/test_workflow_event_snapshot_service.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
"services/workflow/test_workflow_event_snapshot_service_additional.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
"tasks/test_agent_backend_session_cleanup_task.py" = ["ARG005"]
"tasks/test_clean_dataset_task.py" = ["ARG002"]
"tasks/test_clean_document_task.py" = ["ARG002"]
"tasks/test_dataset_indexing_task.py" = ["ARG001", "ARG002"]
"tasks/test_document_indexing_sync_task.py" = ["ARG002"]
"tasks/test_duplicate_document_indexing_task.py" = ["ARG002"]
"tasks/test_human_input_timeout_tasks.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
"tasks/test_initialize_created_app_rbac_access_task.py" = ["ARG005"]
"tasks/test_mail_send_task.py" = ["ARG002"]
"tasks/test_ops_trace_task.py" = ["ARG004"]
"tasks/test_process_tenant_plugin_autoupgrade_check_task.py" = ["ARG001"]
"tasks/test_remove_app_and_related_data_task.py" = ["ARG002"]
"tasks/test_trigger_processing_tasks.py" = ["ARG002"]
"tasks/test_workflow_execute_task.py" = ["ARG005"]
"test_app_factory.py" = ["ARG001"]
"test_pytest_dify.py" = ["ARG001"]
"tools/test_mcp_tool.py" = ["TID251"]
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
msg = "Use Pydantic payload/query models instead of reqparse."
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
msg = "Use Pydantic payload/query models instead of reqparse."
[lint.flake8-tidy-imports.banned-api."typing.Any"]
msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead."
@@ -1,134 +0,0 @@
"""Integration test for the cleanup request against the real agenton compositor.
The bug fixed by A+D was invisible to unit tests that use ``FakeAgentBackendRunClient``
because the fake client never runs agenton's ``_validate_session_snapshot``. This
test plugs a cleanup request through the real ``Compositor`` (with the same
providers the agent backend wires in production) so that the snapshot-vs-
composition name-order check would fail loudly if the cleanup builder ever
regressed back to the empty-composition shape.
"""
from __future__ import annotations
from typing import cast
import pytest
from agenton.compositor import Compositor, CompositorSessionSnapshot, LayerProvider
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID
from agenton_collections.layers.plain.basic import PromptLayer
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryLayer
from clients.agent_backend import AgentBackendRunRequestBuilder, RuntimeLayerSpec
def test_cleanup_request_passes_agenton_snapshot_validation():
"""The cleanup request's composition layer names must match the (filtered)
snapshot's layer names exactly — agenton's compositor enforces this and
the agent backend rejects mismatches as ``run_failed`` asynchronously,
which is the trap A/D fixed."""
# Persisted (non-plugin) layer specs — these are what cleanup will replay.
# We exclude the dify.execution_context layer from this integration check
# because its real provider needs a plugin-daemon HTTP client; the cleanup
# validation we are exercising is the snapshot-vs-composition name check,
# which is purely structural and does not depend on which non-plugin layer
# types appear.
persisted_specs = [
RuntimeLayerSpec(
name="workflow_node_job_prompt",
type=PLAIN_PROMPT_LAYER_TYPE_ID,
config={"prefix": "Do the cleanup."},
),
RuntimeLayerSpec(name="history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
]
# Saved snapshot still carries the LLM layer entry — cleanup's
# ``_filter_snapshot_to_specs`` must drop it so names match.
full_snapshot = CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(
name="workflow_node_job_prompt",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
LayerSessionSnapshot(
name="history",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={"messages": []},
),
LayerSessionSnapshot(
name="llm",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
]
)
cleanup_request = AgentBackendRunRequestBuilder().build_cleanup_request(
session_snapshot=full_snapshot,
runtime_layer_specs=persisted_specs,
)
# Drive the real agenton compositor through ``from_config`` + ``_create_run``
# the same way the agent backend's RunScheduler does. ``_create_run`` is the
# private path that calls ``_validate_session_snapshot``; we use it directly
# to keep the test synchronous (no async ``enter()`` lifecycle needed —
# validation is the only thing under test).
config = {
"schema_version": 1,
"layers": [
{"name": layer.name, "type": layer.type, "deps": dict(layer.deps), "metadata": dict(layer.metadata)}
for layer in cleanup_request.composition.layers
],
}
compositor = Compositor.from_config(
config,
providers=[
LayerProvider.from_layer_type(PromptLayer),
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
],
)
layer_configs = {layer.name: layer.config for layer in cleanup_request.composition.layers}
# This is the call that would raise ``ValueError`` if the cleanup snapshot
# and composition disagreed on layer names — the exact failure mode the
# original ``layers=[]`` cleanup hit.
run = compositor._create_run( # type: ignore[reportPrivateUsage]
configs=cast(dict[str, object], layer_configs),
session_snapshot=cleanup_request.session_snapshot,
)
assert list(run.slots.keys()) == ["workflow_node_job_prompt", "history"]
def test_cleanup_request_with_mismatched_specs_would_be_rejected_by_agenton():
"""Regression sentinel: if a future refactor stops filtering the snapshot,
agenton would reject the request and that rejection is what the runtime
fix is preventing. We confirm the validator does fail when given the
pre-fix shape so the previous test's success is not a coincidence."""
snapshot_with_extra = CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(
name="history",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
LayerSessionSnapshot(
name="llm", # extra layer not in composition
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
]
)
compositor = Compositor.from_config(
{
"schema_version": 1,
"layers": [{"name": "history", "type": PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, "deps": {}, "metadata": {}}],
},
providers=[LayerProvider.from_layer_type(PydanticAIHistoryLayer)],
)
with pytest.raises(ValueError, match="layer names must match"):
compositor._create_run( # type: ignore[reportPrivateUsage]
configs={},
session_snapshot=snapshot_with_extra,
)
@@ -40,6 +40,7 @@ def _request():
agent_mode="workflow_run", agent_mode="workflow_run",
invoke_from="debugger", invoke_from="debugger",
), ),
backend_binding_ref="binding-ref-1",
workflow_node_job_prompt="Do the task.", workflow_node_job_prompt="Do the task.",
user_prompt="hello", user_prompt="hello",
) )
@@ -23,6 +23,7 @@ def _request():
agent_mode="workflow_run", agent_mode="workflow_run",
invoke_from="debugger", invoke_from="debugger",
), ),
backend_binding_ref="binding-ref-1",
workflow_node_job_prompt="Do the task.", workflow_node_job_prompt="Do the task.",
user_prompt="hello", user_prompt="hello",
) )
@@ -1,10 +1,7 @@
from typing import Any, cast from typing import Any, cast
import pytest import pytest
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers import ExitIntent from agenton.layers import ExitIntent
from agenton.layers.base import LifecycleState
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
from dify_agent.layers.dify_core_tools import ( from dify_agent.layers.dify_core_tools import (
@@ -45,8 +42,6 @@ from clients.agent_backend import (
AgentBackendOutputConfig, AgentBackendOutputConfig,
AgentBackendRunRequestBuilder, AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput, AgentBackendWorkflowNodeRunInput,
RuntimeLayerSpec,
extract_runtime_layer_specs,
redact_for_agent_backend_log, redact_for_agent_backend_log,
) )
from clients.agent_backend.request_builder import DIFY_DRIVE_LAYER_ID, DIFY_SHELL_LAYER_ID from clients.agent_backend.request_builder import DIFY_DRIVE_LAYER_ID, DIFY_SHELL_LAYER_ID
@@ -71,6 +66,7 @@ def _run_input() -> AgentBackendWorkflowNodeRunInput:
agent_mode="workflow_run", agent_mode="workflow_run",
invoke_from="debugger", invoke_from="debugger",
), ),
backend_binding_ref="binding-ref-1",
idempotency_key="workflow-run-1:node-execution-1", idempotency_key="workflow-run-1:node-execution-1",
agent_soul_prompt="You are a careful reviewer.", agent_soul_prompt="You are a careful reviewer.",
workflow_node_job_prompt="Review the previous node output.", workflow_node_job_prompt="Review the previous node output.",
@@ -271,89 +267,6 @@ def test_request_builder_adds_knowledge_layer_when_configured():
assert knowledge_config.sets[0].dataset_ids == ["dataset-1"] assert knowledge_config.sets[0].dataset_ids == ["dataset-1"]
def test_request_builder_can_delete_on_exit_for_cleanup_paths():
run_input = _run_input()
run_input.suspend_on_exit = False
request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input)
assert request.on_exit.default is ExitIntent.DELETE
def test_request_builder_builds_cleanup_request_replays_persisted_layer_specs():
"""The cleanup request must replay the persisted (non-plugin) layer specs
and filter the snapshot to match so the agenton compositor's
snapshot-vs-composition name-order validator passes."""
session_snapshot = CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(name="history", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={"k": 1}),
LayerSessionSnapshot(name="llm", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
]
)
specs = [RuntimeLayerSpec(name="history", type="pydantic_ai.history")]
request = AgentBackendRunRequestBuilder().build_cleanup_request(
session_snapshot=session_snapshot,
runtime_layer_specs=specs,
idempotency_key="run-1:node-1:binding-1:agent-session-cleanup",
metadata={"workflow_run_id": "run-1"},
)
assert [layer.name for layer in request.composition.layers] == ["history"]
assert request.session_snapshot is not None
assert [layer.name for layer in request.session_snapshot.layers] == ["history"]
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():
"""Empty specs would put us back in the original ``layers=[]`` trap that
fails on agenton's snapshot-vs-composition validation."""
with pytest.raises(ValueError, match="runtime_layer_specs"):
AgentBackendRunRequestBuilder().build_cleanup_request(
session_snapshot=CompositorSessionSnapshot(layers=[]),
runtime_layer_specs=[],
)
def test_extract_runtime_layer_specs_drops_plugin_layers_keeps_configs():
from dify_agent.protocol import RunComposition, RunLayerSpec
composition = RunComposition(
layers=[
RunLayerSpec(
name="agent_soul_prompt",
type="plain.prompt",
config=PromptLayerConfig(prefix="hello"),
),
RunLayerSpec(
name="llm",
type="dify.plugin.llm",
config=None, # protocol allows None; the redacted config is what matters
),
RunLayerSpec(
name="tools",
type="dify.plugin.tools",
),
RunLayerSpec(
name="history",
type="pydantic_ai.history",
),
]
)
specs = extract_runtime_layer_specs(composition)
assert [spec.name for spec in specs] == ["agent_soul_prompt", "history"]
# Non-plugin configs are dumped as JSON-compatible dicts so the persisted
# row can be replayed without holding live pydantic instances.
soul_config = specs[0].config
assert isinstance(soul_config, dict)
assert soul_config.get("prefix") == "hello"
def test_request_builder_rejects_blank_prompts(): def test_request_builder_rejects_blank_prompts():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
AgentBackendWorkflowNodeRunInput( AgentBackendWorkflowNodeRunInput(
@@ -397,6 +310,7 @@ def _agent_app_input(*, include_shell: bool = False) -> AgentBackendAgentAppRunI
agent_mode="agent_app", agent_mode="agent_app",
invoke_from="web-app", invoke_from="web-app",
), ),
backend_binding_ref="binding-ref-1",
agent_soul_prompt="You are Iris.", agent_soul_prompt="You are Iris.",
user_prompt="List files.", user_prompt="List files.",
include_shell=include_shell, include_shell=include_shell,
@@ -422,7 +336,7 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell():
assert shell.type == DIFY_SHELL_LAYER_TYPE_ID assert shell.type == DIFY_SHELL_LAYER_TYPE_ID
# The shell layer depends on execution_context so the agent server can mint # The shell layer depends on execution_context so the agent server can mint
# per-command Agent Stub env for sandbox CLI forwarding. # per-command Agent Stub env for sandbox CLI forwarding.
assert shell.deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} assert shell.deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, "runtime": "runtime"}
shell_config = cast(DifyShellLayerConfig, shell.config) shell_config = cast(DifyShellLayerConfig, shell.config)
assert shell_config.env[0].name == "PROJECT_NAME" assert shell_config.env[0].name == "PROJECT_NAME"
@@ -436,7 +350,10 @@ def test_workflow_request_builder_binds_drive_to_shell_when_configured():
layers = {layer.name: layer for layer in request.composition.layers} layers = {layer.name: layer for layer in request.composition.layers}
layer_names = [layer.name for layer in request.composition.layers] layer_names = [layer.name for layer in request.composition.layers]
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} assert layers[DIFY_SHELL_LAYER_ID].deps == {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"runtime": "runtime",
}
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config) shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
assert shell_config.agent_stub_drive_ref == "agent-agent-1" assert shell_config.agent_stub_drive_ref == "agent-agent-1"
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID} assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
@@ -470,7 +387,10 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
assert DIFY_SHELL_LAYER_ID in layers assert DIFY_SHELL_LAYER_ID in layers
assert layers[DIFY_SHELL_LAYER_ID].type == DIFY_SHELL_LAYER_TYPE_ID assert layers[DIFY_SHELL_LAYER_ID].type == DIFY_SHELL_LAYER_TYPE_ID
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} assert layers[DIFY_SHELL_LAYER_ID].deps == {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"runtime": "runtime",
}
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config) shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
assert shell_config.env[0].name == "APP_ENV" assert shell_config.env[0].name == "APP_ENV"
@@ -483,7 +403,10 @@ def test_agent_app_request_builder_binds_drive_to_shell_when_configured():
layers = {layer.name: layer for layer in request.composition.layers} layers = {layer.name: layer for layer in request.composition.layers}
layer_names = [layer.name for layer in request.composition.layers] layer_names = [layer.name for layer in request.composition.layers]
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} assert layers[DIFY_SHELL_LAYER_ID].deps == {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"runtime": "runtime",
}
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config) shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
assert shell_config.agent_stub_drive_ref == "agent-agent-1" assert shell_config.agent_stub_drive_ref == "agent-agent-1"
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID} assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
@@ -1,124 +0,0 @@
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"
@@ -85,6 +85,42 @@ def main_branch_rev(repo: Path) -> str:
return git(repo, "rev-parse", "main") return git(repo, "rev-parse", "main")
@pytest.mark.parametrize(
("source_line", "rule_id"),
[
(
"value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy",
"no-new-getattr",
),
(
"session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback",
"no-new-controller-sqlalchemy",
),
],
)
def test_has_reasoned_guard_ignore_accepts_custom_rules(source_line: str, rule_id: str) -> None:
module = load_guard_module()
assert module.has_reasoned_guard_ignore(source_line, rule_id)
@pytest.mark.parametrize(
("source_line", "rule_id"),
[
("value = getattr(module, name) # noqa: no-new-getattr legacy marker", "no-new-getattr"),
("value = getattr(module, name) # guard-ignore: no-new-getattr", "no-new-getattr"),
(
"value = getattr(module, name) # guard-ignore: another-rule -- wrong rule",
"no-new-getattr",
),
],
)
def test_has_reasoned_guard_ignore_rejects_invalid_markers(source_line: str, rule_id: str) -> None:
module = load_guard_module()
assert not module.has_reasoned_guard_ignore(source_line, rule_id)
def test_resolve_ast_grep_command_prefers_ast_grep(monkeypatch: pytest.MonkeyPatch) -> None: def test_resolve_ast_grep_command_prefers_ast_grep(monkeypatch: pytest.MonkeyPatch) -> None:
module = load_guard_module() module = load_guard_module()
monkeypatch.setattr( monkeypatch.setattr(
@@ -776,7 +812,7 @@ def test_modified_hunk_with_increased_getattr_count_fails(tmp_path: Path) -> Non
assert "net-new getattr" in result.stderr assert "net-new getattr" in result.stderr
def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None: def test_inline_guard_ignore_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None:
init_repo(tmp_path) init_repo(tmp_path)
write_repo_file( write_repo_file(
tmp_path, tmp_path,
@@ -795,20 +831,20 @@ def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_p
"pkg/existing.py", "pkg/existing.py",
""" """
def read_value(obj): def read_value(obj):
return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr needed for plugin-defined attributes return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr -- plugin-defined attributes
""", """,
) )
commit_all(tmp_path, "add suppressed getattr") commit_all(tmp_path, "add suppressed getattr")
result = run_script(tmp_path, "--base-rev", base_rev) result = run_script(tmp_path, "--base-rev", base_rev)
assert "no-new-getattr needed for plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text( assert "guard-ignore: no-new-getattr -- plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text(
encoding="utf-8" encoding="utf-8"
) )
assert result.returncode == 0, stderr_lines(result) assert result.returncode == 0, stderr_lines(result)
def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None: def test_inline_guard_ignore_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None:
init_repo(tmp_path) init_repo(tmp_path)
write_repo_file( write_repo_file(
tmp_path, tmp_path,
@@ -827,10 +863,10 @@ def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path)
"pkg/existing.py", "pkg/existing.py",
""" """
def read_value(obj): def read_value(obj):
return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr
""", """,
) )
commit_all(tmp_path, "add bare noqa getattr") commit_all(tmp_path, "add bare guard ignore getattr")
result = run_script(tmp_path, "--base-rev", base_rev) result = run_script(tmp_path, "--base-rev", base_rev)
@@ -61,6 +61,7 @@ def _persist_snapshot(
tenant_id=tenant_id, tenant_id=tenant_id,
agent_id=agent_id, agent_id=agent_id,
version=1, version=1,
home_snapshot_id=_stable_uuid(f"home-snapshot:{snapshot_id}"),
config_snapshot=config_snapshot, config_snapshot=config_snapshot,
) )
session.add(snapshot) session.add(snapshot)
@@ -279,12 +279,7 @@ def test_agent_app_list_and_create_use_agent_route(
) )
monkeypatch.setattr( monkeypatch.setattr(
roster_controller.AgentRosterService, roster_controller.AgentRosterService,
"get_or_create_agent_app_debug_conversation_id", "get_or_create_build_conversation",
lambda _self, **kwargs: "debug-conversation-detail",
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_or_create_agent_app_debug_conversation_id",
lambda _self, **kwargs: "debug-conversation-detail", lambda _self, **kwargs: "debug-conversation-detail",
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -314,7 +309,7 @@ def test_agent_app_list_and_create_use_agent_route(
) )
monkeypatch.setattr( monkeypatch.setattr(
roster_controller.AgentRosterService, roster_controller.AgentRosterService,
"load_or_create_agent_app_debug_conversation_ids_by_agent_id", "load_or_create_build_conversation_ids_by_agent_id",
lambda _self, **kwargs: {"agent-list": "debug-conversation-list"}, lambda _self, **kwargs: {"agent-list": "debug-conversation-list"},
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -327,7 +322,7 @@ def test_agent_app_list_and_create_use_agent_route(
monkeypatch.setattr( monkeypatch.setattr(
roster_controller.AgentRosterService, roster_controller.AgentRosterService,
"get_or_create_agent_app_debug_conversation_id", "get_or_create_build_conversation",
get_or_create_debug_conversation, get_or_create_debug_conversation,
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -387,7 +382,6 @@ def test_agent_app_list_and_create_use_agent_route(
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"agent_id": "agent-created", "agent_id": "agent-created",
"account_id": account_id, "account_id": account_id,
"draft_type": AgentConfigDraftType.DEBUG_BUILD,
"commit": False, "commit": False,
} }
@@ -454,7 +448,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
monkeypatch.setattr(roster_controller.AgentRosterService, "get_app_backing_agent", lambda _self, **kwargs: agent) monkeypatch.setattr(roster_controller.AgentRosterService, "get_app_backing_agent", lambda _self, **kwargs: agent)
monkeypatch.setattr( monkeypatch.setattr(
roster_controller.AgentRosterService, roster_controller.AgentRosterService,
"get_or_create_agent_app_debug_conversation_id", "get_or_create_build_conversation",
lambda _self, **kwargs: "debug-conversation-detail", lambda _self, **kwargs: "debug-conversation-detail",
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -561,25 +555,16 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
} }
@pytest.mark.parametrize( def test_agent_debug_conversation_refresh_resets_build_for_current_user(
("payload", "expected_draft_type"),
[
(None, AgentConfigDraftType.DEBUG_BUILD),
({"draft_type": "draft"}, AgentConfigDraftType.DRAFT),
],
)
def test_agent_debug_conversation_refresh_uses_current_user_and_draft_type(
app: Flask, app: Flask,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
account_id: str, account_id: str,
payload: dict[str, str] | None,
expected_draft_type: AgentConfigDraftType,
) -> None: ) -> None:
agent_id = "00000000-0000-0000-0000-000000000001" agent_id = "00000000-0000-0000-0000-000000000001"
captured: dict[str, object] = {} captured: dict[str, object] = {}
class FakeRosterService: class FakeRosterService:
def refresh_agent_app_debug_conversation_id(self, **kwargs: object) -> str: def reset_build_conversation(self, **kwargs: object) -> str:
captured.update(kwargs) captured.update(kwargs)
return "new-debug-conversation-id" return "new-debug-conversation-id"
@@ -587,7 +572,6 @@ def test_agent_debug_conversation_refresh_uses_current_user_and_draft_type(
with app.test_request_context( with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh", "/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh",
method="POST", method="POST",
json=payload,
): ):
response = unwrap(AgentDebugConversationRefreshApi.post)( response = unwrap(AgentDebugConversationRefreshApi.post)(
AgentDebugConversationRefreshApi(), MagicMock(), "tenant-1", SimpleNamespace(id=account_id), agent_id AgentDebugConversationRefreshApi(), MagicMock(), "tenant-1", SimpleNamespace(id=account_id), agent_id
@@ -601,7 +585,6 @@ def test_agent_debug_conversation_refresh_uses_current_user_and_draft_type(
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"agent_id": agent_id, "agent_id": agent_id,
"account_id": account_id, "account_id": account_id,
"draft_type": expected_draft_type,
} }
@@ -840,7 +823,7 @@ def test_agent_app_update_allows_empty_role(app: Flask, monkeypatch: pytest.Monk
) )
monkeypatch.setattr( monkeypatch.setattr(
roster_controller.AgentRosterService, roster_controller.AgentRosterService,
"get_or_create_agent_app_debug_conversation_id", "get_or_create_build_conversation",
lambda _self, **kwargs: "debug-conversation-detail", lambda _self, **kwargs: "debug-conversation-detail",
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -1203,6 +1186,10 @@ def test_workflow_composer_get_uses_write_transaction() -> None:
assert "@with_session\n @get_app_model" in getsource(WorkflowAgentComposerApi) assert "@with_session\n @get_app_model" in getsource(WorkflowAgentComposerApi)
def test_build_draft_apply_leaves_transaction_ownership_to_service() -> None:
assert "@with_session(write=False)\n def post" in getsource(AgentBuildDraftApplyApi)
def test_workflow_composer_copy_from_roster(app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str) -> None: def test_workflow_composer_copy_from_roster(app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str) -> None:
app_model = SimpleNamespace(id="app-1") app_model = SimpleNamespace(id="app-1")
captured: dict[str, object] = {} captured: dict[str, object] = {}
@@ -1500,7 +1487,7 @@ def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt(
assert args["conversation_id"] == "debug-conversation-1" assert args["conversation_id"] == "debug-conversation-1"
assert args["inputs"] == {} assert args["inputs"] == {}
assert args["auto_generate_name"] is False assert args["auto_generate_name"] is False
assert args[completion_controller.AGENT_RUNTIME_EXIT_INTENT_ARG] == "delete" assert "_agent_runtime_exit_intent" not in args
assert args["external_trace_id"] == "trace-1" assert args["external_trace_id"] == "trace-1"
@@ -1643,7 +1630,7 @@ def test_agent_chat_helper_ignores_private_exit_intent_payload_key(
"inputs": {}, "inputs": {},
"query": "hello", "query": "hello",
"response_mode": "streaming", "response_mode": "streaming",
completion_controller.AGENT_RUNTIME_EXIT_INTENT_ARG: "delete", "_agent_runtime_exit_intent": "delete",
} }
): ):
result = completion_controller._create_chat_message( result = completion_controller._create_chat_message(
@@ -1657,7 +1644,7 @@ def test_agent_chat_helper_ignores_private_exit_intent_payload_key(
args = cast(dict[str, object], captured["args"]) args = cast(dict[str, object], captured["args"])
assert args["response_mode"] == "streaming" assert args["response_mode"] == "streaming"
assert args["conversation_id"] == "debug-conversation-1" assert args["conversation_id"] == "debug-conversation-1"
assert completion_controller.AGENT_RUNTIME_EXIT_INTENT_ARG not in args assert "_agent_runtime_exit_intent" not in args
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -1717,14 +1704,18 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app
def __init__(self, session: object) -> None: def __init__(self, session: object) -> None:
calls.append({"session": session}) calls.append({"session": session})
def get_or_create_agent_app_debug_conversation_id(self, **kwargs: object) -> str: def get_or_create_build_conversation(self, **kwargs: object) -> str:
calls.append({"get_or_create": kwargs}) calls.append({"get_build": kwargs})
return f"debug-{kwargs['agent_id']}" return f"debug-{kwargs['agent_id']}"
def refresh_agent_app_debug_conversation_id(self, **kwargs: object) -> str: def rotate_preview_conversation(self, **kwargs: object) -> str:
calls.append({"refresh": kwargs}) calls.append({"rotate_preview": kwargs})
return f"new-{kwargs['agent_id']}" return f"new-{kwargs['agent_id']}"
def get_current_preview_conversation(self, **kwargs: object) -> str:
calls.append({"get_preview": kwargs})
return f"preview-{kwargs['agent_id']}"
def get_app_backing_agent(self, **kwargs: object) -> object: def get_app_backing_agent(self, **kwargs: object) -> object:
calls.append({"get_app_backing_agent": kwargs}) calls.append({"get_app_backing_agent": kwargs})
return SimpleNamespace(id="backing-agent") return SimpleNamespace(id="backing-agent")
@@ -1756,33 +1747,46 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app
draft_type=AgentConfigDraftType.DRAFT, draft_type=AgentConfigDraftType.DRAFT,
start_new=True, start_new=True,
) )
current_preview_id = completion_controller._resolve_current_user_agent_debug_conversation_id(
session="session-1", # type: ignore[arg-type]
current_tenant_id="tenant-1",
current_user=SimpleNamespace(id="account-1"),
app_model=SimpleNamespace(id="app-1"),
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
)
assert explicit_id == "new-agent-1" assert explicit_id == "new-agent-1"
assert fallback_id == "debug-backing-agent" assert fallback_id == "debug-backing-agent"
assert fallback_preview_id == "new-backing-agent" assert fallback_preview_id == "new-backing-agent"
assert current_preview_id == "preview-agent-1"
assert calls[1] == { assert calls[1] == {
"refresh": { "rotate_preview": {
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"agent_id": "agent-1", "agent_id": "agent-1",
"account_id": "account-1", "account_id": "account-1",
"draft_type": AgentConfigDraftType.DRAFT,
} }
} }
assert calls[3] == {"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "app-1"}} assert calls[3] == {"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "app-1"}}
assert calls[4] == { assert calls[4] == {
"get_or_create": { "get_build": {
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"agent_id": "backing-agent", "agent_id": "backing-agent",
"account_id": "account-1", "account_id": "account-1",
"draft_type": AgentConfigDraftType.DEBUG_BUILD,
} }
} }
assert calls[6] == {"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "app-1"}} assert calls[6] == {"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "app-1"}}
assert calls[7] == { assert calls[7] == {
"refresh": { "rotate_preview": {
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"agent_id": "backing-agent", "agent_id": "backing-agent",
"account_id": "account-1", "account_id": "account-1",
"draft_type": AgentConfigDraftType.DRAFT, }
}
assert calls[9] == {
"get_preview": {
"tenant_id": "tenant-1",
"agent_id": "agent-1",
"account_id": "account-1",
} }
} }
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
from dify_agent.protocol import SandboxListResponse, SandboxReadResponse from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse
from controllers.console import agent_app_sandbox as module from controllers.console import agent_app_sandbox as module
from models.model import App, AppMode, IconType from models.model import App, AppMode, IconType
@@ -15,30 +15,67 @@ from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxIns
class _AgentAppService: class _AgentAppService:
def __init__(self) -> None: def __init__(self) -> None:
self.calls: list[tuple[str, str, str, str, str]] = [] self.calls: list[tuple[str, str, str, str, str, str, str, str]] = []
def get_info(self, *, tenant_id: str, app_id: str, conversation_id: str) -> AgentSandboxInfo: def get_info(
self.calls.append(("info", tenant_id, app_id, conversation_id, "")) self,
return AgentSandboxInfo(session_id="abc1234", workspace_cwd="~/workspace/abc1234") *,
tenant_id: str,
app_id: str,
agent_id: str,
caller_type: str,
caller_id: str,
account_id: str,
) -> AgentSandboxInfo:
self.calls.append(("info", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, ""))
return AgentSandboxInfo(workspace_cwd=".")
def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxListResponse: def list_files(
self.calls.append(("list", tenant_id, app_id, conversation_id, path)) self,
return SandboxListResponse(path=path, entries=[], truncated=False) *,
tenant_id: str,
app_id: str,
agent_id: str,
caller_type: str,
caller_id: str,
account_id: str,
path: str,
) -> WorkspaceListResponse:
self.calls.append(("list", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return WorkspaceListResponse(path=path, entries=[], truncated=False)
def read_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxReadResponse: def read_file(
self.calls.append(("read", tenant_id, app_id, conversation_id, path)) self,
return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello") *,
tenant_id: str,
app_id: str,
agent_id: str,
caller_type: str,
caller_id: str,
account_id: str,
path: str,
) -> WorkspaceReadResponse:
self.calls.append(("read", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return WorkspaceReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
def upload_file( def upload_file(
self, *, tenant_id: str, app_id: str, conversation_id: str, path: str self,
*,
tenant_id: str,
app_id: str,
agent_id: str,
caller_type: str,
caller_id: str,
account_id: str,
path: str,
) -> AgentSandboxUploadDownload: ) -> AgentSandboxUploadDownload:
self.calls.append(("upload", tenant_id, app_id, conversation_id, path)) self.calls.append(("upload", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return AgentSandboxUploadDownload(url="https://files.example/report.txt") return AgentSandboxUploadDownload(url="https://files.example/report.txt")
class _WorkflowService: class _WorkflowService:
def __init__(self) -> None: def __init__(self) -> None:
self.calls: list[tuple[str, str, str, str, str, str | None, str]] = [] self.calls: list[tuple[str, str, str, str, str, str]] = []
def list_files( def list_files(
self, self,
@@ -47,12 +84,12 @@ class _WorkflowService:
app_id: str, app_id: str,
workflow_run_id: str, workflow_run_id: str,
node_id: str, node_id: str,
node_execution_id: str | None, node_execution_id: str,
path: str, path: str,
session, session,
) -> SandboxListResponse: ) -> WorkspaceListResponse:
self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, path))
return SandboxListResponse(path=path, entries=[], truncated=False) return WorkspaceListResponse(path=path, entries=[], truncated=False)
def read_file( def read_file(
self, self,
@@ -61,12 +98,12 @@ class _WorkflowService:
app_id: str, app_id: str,
workflow_run_id: str, workflow_run_id: str,
node_id: str, node_id: str,
node_execution_id: str | None, node_execution_id: str,
path: str, path: str,
session, session,
) -> SandboxReadResponse: ) -> WorkspaceReadResponse:
self.calls.append(("read", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) self.calls.append(("read", tenant_id, app_id, workflow_run_id, node_id, path))
return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello") return WorkspaceReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
def upload_file( def upload_file(
self, self,
@@ -75,11 +112,11 @@ class _WorkflowService:
app_id: str, app_id: str,
workflow_run_id: str, workflow_run_id: str,
node_id: str, node_id: str,
node_execution_id: str | None, node_execution_id: str,
path: str, path: str,
session, session,
) -> AgentSandboxUploadDownload: ) -> AgentSandboxUploadDownload:
self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, path))
return AgentSandboxUploadDownload(url="https://files.example/upload.txt") return AgentSandboxUploadDownload(url="https://files.example/upload.txt")
@@ -125,34 +162,41 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
service = _AgentAppService() service = _AgentAppService()
session = MagicMock() session = MagicMock()
account = SimpleNamespace(id="account-1")
resolver = MagicMock(return_value=_app_model()) resolver = MagicMock(return_value=_app_model())
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service) monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", resolver) monkeypatch.setattr(module, "resolve_agent_runtime_app_model", resolver)
monkeypatch.setattr( monkeypatch.setattr(
module, module,
"query_params_from_request", "query_params_from_request",
lambda model: SimpleNamespace(conversation_id="conv-1", path="sub/report.txt"), lambda model: SimpleNamespace(caller_type="build_draft", caller_id="build-1", path="sub/report.txt"),
) )
monkeypatch.setattr( monkeypatch.setattr(
module, module,
"request", "request",
SimpleNamespace(get_json=lambda silent=True: {"conversation_id": "conv-1", "path": "report.txt"}), SimpleNamespace(
get_json=lambda silent=True: {
"caller_type": "build_draft",
"caller_id": "build-1",
"path": "report.txt",
}
),
) )
info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, "tenant-1", "agent-1") info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, account, "tenant-1", "agent-1")
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), session, "tenant-1", "agent-1") listing = unwrap(module.AgentAppSandboxListResource.get)(object(), session, account, "tenant-1", "agent-1")
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), session, "tenant-1", "agent-1") preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), session, account, "tenant-1", "agent-1")
upload = unwrap(module.AgentAppSandboxUploadResource.post)(object(), session, "tenant-1", "agent-1") upload = unwrap(module.AgentAppSandboxUploadResource.post)(object(), session, account, "tenant-1", "agent-1")
assert info == {"session_id": "abc1234", "workspace_cwd": "~/workspace/abc1234"} assert info == {"workspace_cwd": "."}
assert listing["path"] == "sub/report.txt" assert listing["path"] == "sub/report.txt"
assert preview["text"] == "hello" assert preview["text"] == "hello"
assert upload == {"url": "https://files.example/report.txt"} assert upload == {"url": "https://files.example/report.txt"}
assert service.calls == [ assert service.calls == [
("info", "tenant-1", "app-1", "conv-1", ""), ("info", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", ""),
("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"), ("list", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
("read", "tenant-1", "app-1", "conv-1", "sub/report.txt"), ("read", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
("upload", "tenant-1", "app-1", "conv-1", "report.txt"), ("upload", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "report.txt"),
] ]
assert all(call.kwargs["session"] is session for call in resolver.call_args_list) assert all(call.kwargs["session"] is session for call in resolver.call_args_list)
@@ -160,24 +204,27 @@ def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPat
def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None: def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None:
class FailingService: class FailingService:
def get_info(self, **kwargs): def get_info(self, **kwargs):
raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404) raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
def list_files(self, **kwargs): def list_files(self, **kwargs):
raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404) raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService) monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
session = MagicMock() session = MagicMock()
account = SimpleNamespace(id="account-1")
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", MagicMock(return_value=_app_model())) monkeypatch.setattr(module, "resolve_agent_runtime_app_model", MagicMock(return_value=_app_model()))
monkeypatch.setattr( monkeypatch.setattr(
module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".") module,
"query_params_from_request",
lambda model: SimpleNamespace(caller_type="conversation", caller_id="conv-1", path="."),
) )
assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, "tenant-1", "agent-1") == ( assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, account, "tenant-1", "agent-1") == (
{"code": "no_active_session", "message": "no active session"}, {"code": "no_active_binding", "message": "no active binding"},
404, 404,
) )
assert unwrap(module.AgentAppSandboxListResource.get)(object(), session, "tenant-1", "agent-1") == ( assert unwrap(module.AgentAppSandboxListResource.get)(object(), session, account, "tenant-1", "agent-1") == (
{"code": "no_active_session", "message": "no active session"}, {"code": "no_active_binding", "message": "no active binding"},
404, 404,
) )
@@ -188,12 +235,12 @@ def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.Monk
monkeypatch.setattr( monkeypatch.setattr(
module, module,
"query_params_from_request", "query_params_from_request",
lambda model: SimpleNamespace(node_execution_id="exec-1", path="out.txt"), lambda model: SimpleNamespace(node_execution_id="execution-1", path="out.txt"),
) )
monkeypatch.setattr( monkeypatch.setattr(
module, module,
"request", "request",
SimpleNamespace(get_json=lambda silent=True: {"node_execution_id": "exec-1", "path": "upload.txt"}), SimpleNamespace(get_json=lambda silent=True: {"node_execution_id": "execution-1", "path": "upload.txt"}),
) )
app_model = _app_model() app_model = _app_model()
@@ -211,7 +258,7 @@ def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.Monk
assert preview["text"] == "hello" assert preview["text"] == "hello"
assert upload == {"url": "https://files.example/upload.txt"} assert upload == {"url": "https://files.example/upload.txt"}
assert service.calls == [ assert service.calls == [
("list", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), ("list", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
("read", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), ("read", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
("upload", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "upload.txt"), ("upload", "tenant-1", "app-1", "run-1", "agent-node", "upload.txt"),
] ]
@@ -574,7 +574,7 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest
assert params["avatar"]["required"] is True assert params["avatar"]["required"] is True
def test_console_agent_debug_conversation_refresh_body_is_optional(monkeypatch: pytest.MonkeyPatch): def test_console_agent_debug_conversation_refresh_has_no_body(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config from configs import dify_config
from controllers.console import bp as console_bp from controllers.console import bp as console_bp
@@ -586,13 +586,9 @@ def test_console_agent_debug_conversation_refresh_body_is_optional(monkeypatch:
payload = app.test_client().get("/console/api/openapi.json").get_json() payload = app.test_client().get("/console/api/openapi.json").get_json()
operation = payload["paths"]["/agent/{agent_id}/debug-conversation/refresh"]["post"] operation = payload["paths"]["/agent/{agent_id}/debug-conversation/refresh"]["post"]
request_body = operation["requestBody"]
assert request_body["required"] is False assert "requestBody" not in operation
assert request_body["content"]["application/json"]["schema"] == { assert "AgentDebugConversationRefreshPayload" not in payload["components"]["schemas"]
"$ref": "#/components/schemas/AgentDebugConversationRefreshPayload"
}
assert "AgentDebugConversationRefreshPayload" in payload["components"]["schemas"]
def test_console_member_invite_documents_bad_request_response(monkeypatch: pytest.MonkeyPatch): def test_console_member_invite_documents_bad_request_response(monkeypatch: pytest.MonkeyPatch):
@@ -6,13 +6,19 @@ from unittest.mock import ANY, MagicMock, patch
import pytest import pytest
from flask import Flask from flask import Flask
from jwt import InvalidTokenError from jwt import InvalidTokenError
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from werkzeug.exceptions import Unauthorized from werkzeug.exceptions import Unauthorized
import services.errors.account import services.errors.account
from controllers.console import wraps as console_wraps
from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi
from enums.deployment_edition import DeploymentEdition from enums.deployment_edition import DeploymentEdition
from models.model import DifySetup
from services.entities.auth_entities import LoginFailureReason from services.entities.auth_entities import LoginFailureReason
pytestmark = pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def encode_code(code: str) -> str: def encode_code(code: str) -> str:
return base64.b64encode(code.encode("utf-8")).decode() return base64.b64encode(code.encode("utf-8")).decode()
@@ -33,17 +39,27 @@ def app():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _patch_wraps(): def _patch_wraps(
monkeypatch: pytest.MonkeyPatch,
sqlite_engine: Engine,
sqlite_session: Session,
):
wraps_features = SimpleNamespace(enable_email_password_login=True) wraps_features = SimpleNamespace(enable_email_password_login=True)
console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
web_dify = SimpleNamespace(ENTERPRISE_ENABLED=True) web_dify = SimpleNamespace(ENTERPRISE_ENABLED=True)
sqlite_session.add(DifySetup(version="test"))
sqlite_session.commit()
console_wraps._is_setup_completed.reset_success()
session_registry = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False))
monkeypatch.setattr(console_wraps.db, "session", session_registry)
with ( with (
patch("controllers.console.wraps.db") as mock_db,
patch("controllers.console.wraps.dify_config", console_dify), patch("controllers.console.wraps.dify_config", console_dify),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
patch("controllers.web.login.dify_config", web_dify), patch("controllers.web.login.dify_config", web_dify),
): ):
yield yield
session_registry.remove()
console_wraps._is_setup_completed.reset_success()
class TestEmailCodeLoginSendEmailApi: class TestEmailCodeLoginSendEmailApi:
@@ -71,6 +71,7 @@ def _add_agent(
tenant_id="tenant-1", tenant_id="tenant-1",
agent_id=agent_id, agent_id=agent_id,
version=1, version=1,
home_snapshot_id=f"home-{agent_id}",
config_snapshot=_agent_soul(), config_snapshot=_agent_soul(),
) )
) )
@@ -245,6 +246,7 @@ def test_workflow_callable_filter_distinguishes_never_published_from_dirty_draft
tenant_id="tenant-1", tenant_id="tenant-1",
agent_id=stale_published_snapshot.id, agent_id=stale_published_snapshot.id,
version=2, version=2,
home_snapshot_id="home-stale-published-old",
config_snapshot=_agent_soul(), config_snapshot=_agent_soul(),
) )
) )
@@ -22,11 +22,10 @@ from core.app.apps.agent_app.app_generator import (
AgentAppGeneratorError, AgentAppGeneratorError,
) )
from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import AGENT_RUNTIME_EXIT_INTENT_ARG, InvokeFrom, UserFrom from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.entities.queue_entities import QueueAnnotationReplyEvent from core.app.entities.queue_entities import QueueAnnotationReplyEvent
from core.workflow.file_reference import build_file_reference from core.workflow.file_reference import build_file_reference
from models import Account, AppModelConfig from models import Account, AppModelConfig
from models.agent import AgentConfigDraftType
MODULE = "core.app.apps.agent_app.app_generator" MODULE = "core.app.apps.agent_app.app_generator"
@@ -79,14 +78,18 @@ class TestGenerateGuards:
class TestGenerateSuccess: class TestGenerateSuccess:
def test_runtime_session_snapshot_id_preserves_snapshot_for_debugger_and_web_app(self): def test_session_scope_config_version_id_preserves_draft_or_snapshot_id(self):
assert ( assert (
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1") AgentAppGenerator._session_scope_config_version_id(
== "snap-1" invoke_from=InvokeFrom.DEBUGGER, config_version_id="draft-1"
)
== "draft-1"
) )
assert ( assert (
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.WEB_APP, snapshot_id="snap-1") AgentAppGenerator._session_scope_config_version_id(
== "snap-1" invoke_from=InvokeFrom.WEB_APP, config_version_id="snapshot-1"
)
== "snapshot-1"
) )
def test_generate_orchestrates_and_starts_worker(self, generator, mocker: MockerFixture): def test_generate_orchestrates_and_starts_worker(self, generator, mocker: MockerFixture):
@@ -145,84 +148,11 @@ class TestGenerateSuccess:
draft_type=None, draft_type=None,
user=user, user=user,
session=session, session=session,
conversation=None,
) )
session.get.assert_called_once_with(AppModelConfig, "config-1") session.get.assert_called_once_with(AppModelConfig, "config-1")
assert generate_entity.call_args.kwargs["prompt_file_mappings"] == file_mappings assert generate_entity.call_args.kwargs["prompt_file_mappings"] == file_mappings
assert generate_entity.call_args.kwargs["agent_runtime_exit_intent"] == "suspend" assert "agent_runtime_exit_intent" not in generate_entity.call_args.kwargs
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,
session=mocker.MagicMock(),
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,
session=mocker.MagicMock(),
streaming=True,
)
assert generate_entity.call_args.kwargs["agent_runtime_exit_intent"] == "suspend"
def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture): def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
@@ -263,6 +193,7 @@ class TestGenerateSuccess:
user=user, user=user,
session=session, session=session,
) )
assert generator._resolve_agent.call_args.kwargs["conversation"].id == "conv"
assert generator._init_generate_records.call_args.kwargs["session"] is session assert generator._init_generate_records.call_args.kwargs["session"] is session
def test_generate_does_not_include_trace_session_id_in_extras( def test_generate_does_not_include_trace_session_id_in_extras(
@@ -326,8 +257,10 @@ class TestGenerateWorker:
generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock(id="conv")) generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock(id="conv"))
generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock(id="msg")) generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock(id="msg"))
generator._run_input_guards = mocker.MagicMock(return_value=(handled, guard_query, None)) generator._run_input_guards = mocker.MagicMock(return_value=(handled, guard_query, None))
resolved_agent = mocker.MagicMock(id="a")
resolved_config = mocker.MagicMock(id="s", home_snapshot_id="home-1")
generator._resolve_agent_by_id = mocker.MagicMock( generator._resolve_agent_by_id = mocker.MagicMock(
return_value=(mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock()) return_value=(resolved_agent, resolved_config, mocker.MagicMock())
) )
session = mocker.MagicMock() session = mocker.MagicMock()
session.get.return_value = mocker.MagicMock(id="app1") session.get.return_value = mocker.MagicMock(id="app1")
@@ -345,7 +278,7 @@ class TestGenerateWorker:
mocker.patch(f"{MODULE}.AgentAppRuntimeRequestBuilder", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.AgentAppRuntimeRequestBuilder", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.create_agent_backend_run_client", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.create_agent_backend_run_client", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.AgentBackendRunEventAdapter", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.AgentBackendRunEventAdapter", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.AgentAppRuntimeSessionStore", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.AgentAppWorkspaceStore", return_value=mocker.MagicMock())
runner = mocker.MagicMock() runner = mocker.MagicMock()
if run_side_effect is not None: if run_side_effect is not None:
runner.run.side_effect = run_side_effect runner.run.side_effect = run_side_effect
@@ -360,9 +293,8 @@ class TestGenerateWorker:
*, *,
is_resume=False, is_resume=False,
query="query", query="query",
runtime_session_snapshot_id="s", session_scope_config_version_id="s",
prompt_file_mappings=(), prompt_file_mappings=(),
agent_runtime_exit_intent="suspend",
): ):
generator._generate_worker( generator._generate_worker(
flask_app=mocker.MagicMock(), flask_app=mocker.MagicMock(),
@@ -370,8 +302,7 @@ class TestGenerateWorker:
application_generate_entity=mocker.MagicMock( application_generate_entity=mocker.MagicMock(
agent_id="a", agent_id="a",
agent_config_snapshot_id="s", agent_config_snapshot_id="s",
agent_runtime_session_snapshot_id=runtime_session_snapshot_id, agent_session_scope_config_version_id=session_scope_config_version_id,
agent_runtime_exit_intent=agent_runtime_exit_intent,
model_conf=mocker.MagicMock(model="m"), model_conf=mocker.MagicMock(model="m"),
query=query, query=query,
prompt_file_mappings=prompt_file_mappings, prompt_file_mappings=prompt_file_mappings,
@@ -389,25 +320,19 @@ class TestGenerateWorker:
self._call(generator, mocker, queue_manager) self._call(generator, mocker, queue_manager)
runner.run.assert_called_once() runner.run.assert_called_once()
assert generator._resolve_agent_by_id.call_args.kwargs["session"] is resolver_session assert generator._resolve_agent_by_id.call_args.kwargs["session"] is resolver_session
assert runner.run.call_args.kwargs["home_snapshot_id"] == "home-1"
assert "home_snapshot_ref" not in runner.run.call_args.kwargs
queue_manager.publish_error.assert_not_called() queue_manager.publish_error.assert_not_called()
def test_worker_passes_runtime_session_scope_to_runner(self, generator, mocker: MockerFixture): def test_worker_passes_session_scope_config_version_to_runner(self, generator, mocker: MockerFixture):
runner, _ = self._wire(generator, mocker) runner, _ = self._wire(generator, mocker)
queue_manager = mocker.MagicMock() queue_manager = mocker.MagicMock()
self._call(generator, mocker, queue_manager, runtime_session_snapshot_id=None) self._call(generator, mocker, queue_manager, session_scope_config_version_id=None)
assert runner.run.call_args.kwargs["agent_config_snapshot_id"] == "s" assert runner.run.call_args.kwargs["agent_config_snapshot_id"] == "s"
assert runner.run.call_args.kwargs["session_scope_snapshot_id"] is None 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): def test_worker_appends_prompt_files_to_backend_query(self, generator, mocker: MockerFixture):
runner, _ = self._wire(generator, mocker, guard_query="你看得见这张图片吗") runner, _ = self._wire(generator, mocker, guard_query="你看得见这张图片吗")
queue_manager = mocker.MagicMock() queue_manager = mocker.MagicMock()
@@ -526,7 +451,7 @@ class TestResumeAfterFormSubmission:
mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.AgentAppRuntimeSessionStore") generator._resolve_resume_draft = mocker.MagicMock(return_value=(None, None))
return ( return (
mocker.patch( mocker.patch(
f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user") f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user")
@@ -547,6 +472,7 @@ class TestResumeAfterFormSubmission:
app_model=app_model, app_model=app_model,
user=user, user=user,
conversation_id="conv", conversation_id="conv",
form_id="form-1",
invoke_from=InvokeFrom.WEB_APP, invoke_from=InvokeFrom.WEB_APP,
session=session, session=session,
) )
@@ -573,6 +499,7 @@ class TestResumeAfterFormSubmission:
app_model=mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent"), app_model=mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent"),
user=DummyAccount("user"), user=DummyAccount("user"),
conversation_id="conv", conversation_id="conv",
form_id="form-1",
invoke_from=InvokeFrom.WEB_APP, invoke_from=InvokeFrom.WEB_APP,
session=session, session=session,
) )
@@ -584,25 +511,24 @@ class TestResumeAfterFormSubmission:
self._wire(generator, mocker) self._wire(generator, mocker)
conversation = mocker.MagicMock(id="conv", invoke_from=InvokeFrom.DEBUGGER) conversation = mocker.MagicMock(id="conv", invoke_from=InvokeFrom.DEBUGGER)
mocker.patch(f"{MODULE}.ConversationService.get_conversation", return_value=conversation) mocker.patch(f"{MODULE}.ConversationService.get_conversation", return_value=conversation)
session_store = mocker.patch(f"{MODULE}.AgentAppRuntimeSessionStore") generator._resolve_resume_draft.return_value = ("debug_build", "draft-build-1")
session_store.return_value.load_active_session_for_conversation.return_value = mocker.MagicMock(
scope=mocker.MagicMock(agent_config_snapshot_id="draft-build-1")
)
draft_row = mocker.MagicMock(draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="user")
account_user = mocker.MagicMock(spec=Account) account_user = mocker.MagicMock(spec=Account)
account_user.id = "user" account_user.id = "user"
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
app_model.app_model_config_id = "config-1" app_model.app_model_config_id = "config-1"
session = mocker.MagicMock() session = mocker.MagicMock()
session.scalar.side_effect = [draft_row, mocker.MagicMock(query="original question")] session.scalar.return_value = mocker.MagicMock(query="original question")
generator.resume_after_form_submission( generator.resume_after_form_submission(
app_model=app_model, app_model=app_model,
user=account_user, user=account_user,
conversation_id="conv", conversation_id="conv",
form_id="form-1",
invoke_from=InvokeFrom.DEBUGGER, invoke_from=InvokeFrom.DEBUGGER,
session=session, session=session,
) )
assert generator._resolve_agent.call_args.kwargs["draft_type"] == "debug_build" assert generator._resolve_agent.call_args.kwargs["draft_type"] == "debug_build"
assert generator._resolve_agent.call_args.kwargs["draft_id"] == "draft-build-1"
assert generator._resolve_agent.call_args.kwargs["session"] is session assert generator._resolve_agent.call_args.kwargs["session"] is session
assert generator._resolve_agent.call_args.kwargs["conversation"] is conversation
@@ -23,7 +23,6 @@ from dify_agent.protocol import (
RunStartedEvent, RunStartedEvent,
RunSucceededEvent, RunSucceededEvent,
RunSucceededEventData, RunSucceededEventData,
RuntimeLayerSpec,
) )
from pydantic_ai.messages import ( from pydantic_ai.messages import (
FunctionToolCallEvent, FunctionToolCallEvent,
@@ -36,7 +35,6 @@ from pydantic_ai.messages import (
) )
from clients.agent_backend import ( from clients.agent_backend import (
AgentBackendError,
AgentBackendRunEventAdapter, AgentBackendRunEventAdapter,
AgentBackendRunFailedError, AgentBackendRunFailedError,
AgentBackendRunFailedInternalEvent, AgentBackendRunFailedInternalEvent,
@@ -155,43 +153,6 @@ class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
) )
class _StreamingRecordingFakeAgentBackendRunClient(_RecordingFakeAgentBackendRunClient):
@override
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
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": "hello agent"},
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
class _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(_RecordingFakeAgentBackendRunClient): class _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(_RecordingFakeAgentBackendRunClient):
def __init__(self, *, queue_manager: _FakeQueueManager, **kwargs: Any) -> None: def __init__(self, *, queue_manager: _FakeQueueManager, **kwargs: Any) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -419,60 +380,48 @@ class _FakeSessionStore:
self, self,
loaded: CompositorSessionSnapshot | None = None, loaded: CompositorSessionSnapshot | None = None,
loaded_session: StoredAgentAppSession | None = None, loaded_session: StoredAgentAppSession | None = None,
listed_sessions: list[StoredAgentAppSession] | None = None, binding_id: str = "binding-1",
workspace_id: str = "workspace-1",
backend_binding_ref: str = "backend-binding-1",
) -> None: ) -> None:
self.loaded = loaded self.loaded = loaded
self._loaded_session = loaded_session self._loaded_session = loaded_session
self._listed_sessions = list(listed_sessions or []) self.binding_id = binding_id
self.loaded_scopes: list[AgentAppSessionScope] = [] self.workspace_id = workspace_id
self.backend_binding_ref = backend_binding_ref
self.resolved_scopes: list[AgentAppSessionScope] = []
self.saved: list[ self.saved: list[
tuple[ tuple[
AgentAppSessionScope, AgentAppSessionScope,
str, str,
CompositorSessionSnapshot | None, CompositorSessionSnapshot | None,
list[RuntimeLayerSpec],
str | None, str | None,
str | None, str | None,
] ]
] = [] ] = []
self.cleaned: list[tuple[AgentAppSessionScope, str | None]] = []
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None: def load_or_create(self, scope: AgentAppSessionScope) -> StoredAgentAppSession:
self.loaded_scopes.append(scope) self.resolved_scopes.append(scope)
return self.loaded
def load_active_session(self, scope: AgentAppSessionScope) -> StoredAgentAppSession | None:
self.loaded_scopes.append(scope)
if self._loaded_session is not None: if self._loaded_session is not None:
return self._loaded_session return self._loaded_session
if self.loaded is None: return StoredAgentAppSession(
return None scope=scope,
return StoredAgentAppSession(scope=scope, session_snapshot=self.loaded, backend_run_id=None) binding_id=self.binding_id,
workspace_id=self.workspace_id,
def list_active_sessions_for_conversation( backend_binding_ref=self.backend_binding_ref,
self, *, tenant_id: str, app_id: str, conversation_id: str session_snapshot=self.loaded,
) -> 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( def save_active_snapshot(
self, self,
*, *,
scope, scope: AgentAppSessionScope,
backend_run_id, binding_id: str,
snapshot, snapshot: CompositorSessionSnapshot | None,
runtime_layer_specs, pending_form_id: str | None = None,
pending_form_id=None, pending_tool_call_id: str | None = None,
pending_tool_call_id=None,
) -> None: ) -> None:
self.saved.append( self.saved.append((scope, binding_id, snapshot, pending_form_id, pending_tool_call_id))
(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: class _MonotonicClock:
@@ -529,18 +478,18 @@ def _runner(
) )
def _run(runner: AgentAppRunner, qm: _FakeQueueManager, *, agent_runtime_exit_intent: str = "suspend") -> None: def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None:
runner.run( runner.run(
dify_context=_dify_ctx(), dify_context=_dify_ctx(),
agent_id="agent-1", agent_id="agent-1",
agent_config_snapshot_id="snap-1", agent_config_snapshot_id="snap-1",
agent_soul=_soul(), agent_soul=_soul(),
home_snapshot_id="home-1",
conversation_id="conv-1", conversation_id="conv-1",
query="hello", query="hello",
message_id="msg-1", message_id="msg-1",
model_name="gpt-4o-mini", model_name="gpt-4o-mini",
queue_manager=qm, # type: ignore[arg-type] queue_manager=qm, # type: ignore[arg-type]
agent_runtime_exit_intent=agent_runtime_exit_intent, # type: ignore[arg-type]
) )
@@ -578,134 +527,27 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session():
assert _saved_user_query(qm) == "hello" assert _saved_user_query(qm) == "hello"
# The conversation session snapshot is persisted for multi-turn continuity. # The conversation session snapshot is persisted for multi-turn continuity.
assert store.saved assert store.saved
saved_scope, saved_run_id, saved_snapshot, saved_specs, pending_form_id, pending_tool_call_id = store.saved[0] saved_scope, saved_binding_id, saved_snapshot, pending_form_id, pending_tool_call_id = store.saved[0]
assert saved_scope.conversation_id == "conv-1" assert saved_scope.conversation_id == "conv-1"
assert saved_scope.agent_config_snapshot_id == "snap-1" assert saved_scope.agent_config_snapshot_id == "snap-1"
assert saved_run_id == "fake-run-1" assert saved_binding_id == "binding-1"
assert saved_snapshot is not None assert saved_snapshot is not None
assert saved_specs
# A successful turn carries no ask_human pause correlation. # A successful turn carries no ask_human pause correlation.
assert pending_form_id is None assert pending_form_id is None
assert pending_tool_call_id is None assert pending_tool_call_id is None
assert store.cleaned == []
def test_successful_turn_enqueues_cleanup_for_superseded_sessions_after_saving_snapshot(monkeypatch): def test_turn_uses_resolved_backend_binding_before_backend_invocation() -> None:
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() client = FakeAgentBackendRunClient()
qm = _FakeQueueManager() store = _FakeSessionStore(binding_id="binding-2", backend_binding_ref="backend-binding-2")
cleanup_delay = MagicMock()
monkeypatch.setattr(app_runner_module.cleanup_conversation_agent_runtime_session, "delay", cleanup_delay)
_run(_runner(client, store), qm) _run(_runner(client, store), _FakeQueueManager())
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 is not None
assert client.request.on_exit.default.value == "delete" layers = {layer["name"]: layer for layer in client.request.model_dump(mode="json")["composition"]["layers"]}
assert store.saved == [] assert layers["runtime"]["config"]["backend_binding_ref"] == "backend-binding-2"
assert len(store.cleaned) == 1 assert store.saved[0][1] == "binding-2"
cleaned_scope, cleaned_run_id = store.cleaned[0] assert len(store.resolved_scopes) == 1
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): def test_successful_turn_routes_stream_text_to_agent_message_and_uses_terminal_output(monkeypatch):
@@ -1254,6 +1096,7 @@ def test_debug_session_scope_can_reuse_conversation_across_config_snapshots():
agent_id="agent-1", agent_id="agent-1",
agent_config_snapshot_id="snap-new", agent_config_snapshot_id="snap-new",
agent_soul=_soul(), agent_soul=_soul(),
home_snapshot_id="home-1",
conversation_id="conv-1", conversation_id="conv-1",
query="hello", query="hello",
message_id="msg-1", message_id="msg-1",
@@ -1264,8 +1107,8 @@ def test_debug_session_scope_can_reuse_conversation_across_config_snapshots():
assert client.request is not None assert client.request is not None
assert client.request.session_snapshot is prior assert client.request.session_snapshot is prior
assert store.loaded_scopes[0].agent_config_snapshot_id is None assert store.resolved_scopes[0].agent_config_snapshot_id == "snap-new"
assert store.saved[0][0].agent_config_snapshot_id is None assert store.saved[0][0].agent_config_snapshot_id == "snap-new"
def test_failed_run_raises_agent_backend_error(): def test_failed_run_raises_agent_backend_error():
@@ -1358,24 +1201,8 @@ def test_ask_human_pauses_turn_creates_form_and_persists_correlation():
assert _saved_user_query(qm) == "hello" assert _saved_user_query(qm) == "hello"
# The pause correlation is persisted so a form submission can resume the run. # The pause correlation is persisted so a form submission can resume the run.
assert store.saved assert store.saved
assert store.saved[0][4] == "form-1" assert store.saved[0][3] == "form-1"
assert store.saved[0][5] == "fake-ask-human-1" assert store.saved[0][4] == "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): def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch):
@@ -1389,9 +1216,12 @@ def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch):
conversation_id="conv-1", conversation_id="conv-1",
agent_id="agent-1", agent_id="agent-1",
agent_config_snapshot_id="snap-1", agent_config_snapshot_id="snap-1",
home_snapshot_id="home-1",
), ),
binding_id="binding-1",
workspace_id="workspace-1",
backend_binding_ref="backend-binding-1",
session_snapshot=snapshot, session_snapshot=snapshot,
backend_run_id="run-0",
pending_form_id="form-1", pending_form_id="form-1",
pending_tool_call_id="call-1", pending_tool_call_id="call-1",
) )
@@ -9,13 +9,18 @@ from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import MagicMock
import pytest import pytest
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError, AgentAppNotPublishedError from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.app_invoke_entities import InvokeFrom
from models.agent import AgentConfigDraft, AgentConfigDraftType, AgentScope, AgentSource from models.agent import AgentConfigDraft, AgentConfigDraftType, AgentConfigVersionKind, AgentScope, AgentSource
from models.agent_config_entities import AgentSoulConfig from models.agent_config_entities import AgentSoulConfig
from services.agent.workspace_service import (
AgentWorkspaceBindingGenerationMismatchError,
AgentWorkspaceService,
)
_SOUL_DICT = { _SOUL_DICT = {
"model": { "model": {
@@ -34,8 +39,10 @@ class _FakeScalarSession:
self._values = list(values) self._values = list(values)
self.added: list[Any] = [] self.added: list[Any] = []
self.flush_count = 0 self.flush_count = 0
self.scalar_statements: list[Any] = []
def scalar(self, _stmt: Any) -> Any: def scalar(self, stmt: Any) -> Any:
self.scalar_statements.append(stmt)
return self._values.pop(0) if self._values else None return self._values.pop(0) if self._values else None
def add(self, value: Any) -> None: def add(self, value: Any) -> None:
@@ -46,7 +53,7 @@ class _FakeScalarSession:
def _snapshot() -> SimpleNamespace: def _snapshot() -> SimpleNamespace:
return SimpleNamespace(id="snap-1", config_snapshot_dict=_SOUL_DICT) return SimpleNamespace(id="snap-1", home_snapshot_id="home-1", config_snapshot_dict=_SOUL_DICT)
class TestResolveAgentById: class TestResolveAgentById:
@@ -102,6 +109,7 @@ class TestResolveDebugDraft:
tenant_id="t1", tenant_id="t1",
agent=agent, agent=agent,
draft_type=None, draft_type=None,
draft_id=None,
account_id=None, account_id=None,
session=session, session=session,
) )
@@ -127,10 +135,12 @@ class TestResolveDebugDraft:
account_id=None, account_id=None,
draft_owner_key="", draft_owner_key="",
base_snapshot_id="snap-1", base_snapshot_id="snap-1",
home_snapshot_id="home-1",
config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "old"}}), config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "old"}}),
) )
active_snapshot = SimpleNamespace( active_snapshot = SimpleNamespace(
id="snap-2", id="snap-2",
home_snapshot_id="home-2",
config_snapshot_dict={"prompt": {"system_prompt": "new"}}, config_snapshot_dict={"prompt": {"system_prompt": "new"}},
) )
session = _FakeScalarSession([draft, active_snapshot]) session = _FakeScalarSession([draft, active_snapshot])
@@ -146,6 +156,7 @@ class TestResolveDebugDraft:
assert resolved is draft assert resolved is draft
assert resolved.id == "draft-1" assert resolved.id == "draft-1"
assert resolved.base_snapshot_id == "snap-2" assert resolved.base_snapshot_id == "snap-2"
assert resolved.home_snapshot_id == "home-2"
assert resolved.config_snapshot_dict["prompt"]["system_prompt"] == "new" assert resolved.config_snapshot_dict["prompt"]["system_prompt"] == "new"
assert session.flush_count == 1 assert session.flush_count == 1
@@ -165,6 +176,7 @@ class TestResolveDebugDraft:
account_id="account-1", account_id="account-1",
draft_owner_key="account-1", draft_owner_key="account-1",
base_snapshot_id="snap-1", base_snapshot_id="snap-1",
home_snapshot_id="home-build",
config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "build edit"}}), config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "build edit"}}),
) )
session = _FakeScalarSession([draft]) session = _FakeScalarSession([draft])
@@ -182,6 +194,48 @@ class TestResolveDebugDraft:
assert resolved.config_snapshot_dict["prompt"]["system_prompt"] == "build edit" assert resolved.config_snapshot_dict["prompt"]["system_prompt"] == "build edit"
assert session.flush_count == 0 assert session.flush_count == 0
def test_build_draft_uses_exact_draft_id(self):
agent = SimpleNamespace(
id="agent-1",
scope=AgentScope.WORKFLOW_ONLY,
active_config_snapshot_id="snap-2",
created_by="creator-1",
updated_by="updater-1",
)
draft = AgentConfigDraft(
id="exact-build-draft",
tenant_id="t1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-1",
draft_owner_key="account-1",
base_snapshot_id="snap-1",
home_snapshot_id="home-build",
config_snapshot=AgentSoulConfig(),
)
session = _FakeScalarSession([draft])
statements: list[Any] = []
scalar = session.scalar
def capture_scalar(statement: Any) -> Any:
statements.append(statement)
return scalar(statement)
session.scalar = capture_scalar # type: ignore[method-assign]
resolved = AgentAppGenerator._resolve_debug_draft(
tenant_id="t1",
agent=agent,
draft_type=AgentConfigDraftType.DEBUG_BUILD.value,
draft_id="exact-build-draft",
account_id="account-1",
session=session,
)
assert resolved is draft
assert "agent_config_drafts.id =" in str(statements[0])
assert "exact-build-draft" in statements[0].compile().params.values()
class TestResolveAgent: class TestResolveAgent:
def test_success_chains_to_resolve_by_id(self): def test_success_chains_to_resolve_by_id(self):
@@ -235,6 +289,126 @@ class TestResolveAgent:
assert config_version_kind == "snapshot" assert config_version_kind == "snapshot"
assert soul.prompt.system_prompt == "You are Iris." assert soul.prompt.system_prompt == "You are Iris."
def test_existing_conversation_resolves_binding_snapshot_instead_of_latest_active_snapshot(
self, monkeypatch: pytest.MonkeyPatch
):
bound_agent = SimpleNamespace(
id="agent-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="snap-2",
active_config_is_published=True,
)
inner_agent = SimpleNamespace(id="agent-1")
pinned_snapshot = SimpleNamespace(
id="snap-1",
home_snapshot_id="home-1",
config_snapshot_dict=_SOUL_DICT,
)
conversation = SimpleNamespace(id="conversation-1", agent_workspace_binding_id="binding-1")
binding = SimpleNamespace(
id="binding-1",
agent_id="agent-1",
base_home_snapshot_id="home-1",
agent_config_version_id="snap-1",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
)
get_active_binding = MagicMock(return_value=binding)
validate_generation = MagicMock()
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_active_binding)
monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", validate_generation)
session = _FakeScalarSession([bound_agent, inner_agent, pinned_snapshot])
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
_, config_id, config_version_kind, soul = AgentAppGenerator()._resolve_agent(
app_model,
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=SimpleNamespace(id="user-1"),
session=session,
conversation=conversation,
) # type: ignore[arg-type]
assert config_id == "snap-1"
assert config_version_kind == "snapshot"
assert soul.prompt.system_prompt == "You are Iris."
assert get_active_binding.call_args.kwargs["binding_id"] == "binding-1"
validate_generation.assert_called_once_with(
binding,
base_home_snapshot_id="home-1",
agent_config_version_id="snap-1",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
)
def test_existing_conversation_rejects_unavailable_binding(self, monkeypatch: pytest.MonkeyPatch):
bound_agent = SimpleNamespace(
id="agent-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="snap-active",
active_config_is_published=True,
)
conversation = SimpleNamespace(id="conversation-1", agent_workspace_binding_id="binding-missing")
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=None))
with pytest.raises(AgentAppGeneratorError, match="Conversation participant Binding is unavailable"):
AgentAppGenerator()._resolve_agent(
SimpleNamespace(id="app-1", tenant_id="t1"),
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=SimpleNamespace(id="user-1"),
session=_FakeScalarSession([bound_agent]),
conversation=conversation,
) # type: ignore[arg-type]
@pytest.mark.parametrize(
("binding_home_id", "binding_version_kind", "snapshot_home_id"),
[
("home-binding", AgentConfigVersionKind.SNAPSHOT, "home-other"),
("home-pinned", AgentConfigVersionKind.DRAFT, "home-pinned"),
],
)
def test_existing_conversation_generation_mismatch_does_not_fallback_to_active_snapshot(
self,
monkeypatch: pytest.MonkeyPatch,
binding_home_id: str,
binding_version_kind: AgentConfigVersionKind,
snapshot_home_id: str,
):
bound_agent = SimpleNamespace(
id="agent-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="snap-active",
active_config_is_published=True,
)
conversation = SimpleNamespace(id="conversation-1", agent_workspace_binding_id="binding-1")
binding = SimpleNamespace(
id="binding-1",
agent_id="agent-1",
base_home_snapshot_id=binding_home_id,
agent_config_version_id="snap-pinned",
agent_config_version_kind=binding_version_kind,
)
pinned_snapshot = SimpleNamespace(
id="snap-pinned",
home_snapshot_id=snapshot_home_id,
config_snapshot_dict=_SOUL_DICT,
)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding))
session = _FakeScalarSession([bound_agent, SimpleNamespace(id="agent-1"), pinned_snapshot])
with pytest.raises(AgentWorkspaceBindingGenerationMismatchError):
AgentAppGenerator()._resolve_agent(
SimpleNamespace(id="app-1", tenant_id="t1"),
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=SimpleNamespace(id="user-1"),
session=session,
conversation=conversation,
) # type: ignore[arg-type]
snapshot_query_params = session.scalar_statements[-1].compile().params.values()
assert "snap-pinned" in snapshot_query_params
assert "snap-active" not in snapshot_query_params
def test_unpublished_imported_agent_is_not_available_to_public_runtime(self): def test_unpublished_imported_agent_is_not_available_to_public_runtime(self):
bound_agent = SimpleNamespace( bound_agent = SimpleNamespace(
id="agent-1", id="agent-1",
@@ -44,6 +44,7 @@ class TestBuildForAgentApp:
AgentBackendAgentAppRunInput( AgentBackendAgentAppRunInput(
model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
execution_context=_exec_ctx(), execution_context=_exec_ctx(),
backend_binding_ref="binding-ref-1",
user_prompt="hello", user_prompt="hello",
agent_soul_prompt="You are Iris.", agent_soul_prompt="You are Iris.",
) )
@@ -65,6 +66,7 @@ class TestBuildForAgentApp:
AgentBackendAgentAppRunInput( AgentBackendAgentAppRunInput(
model=AgentBackendModelConfig(plugin_id="p/q", model_provider="openai", model="m"), model=AgentBackendModelConfig(plugin_id="p/q", model_provider="openai", model="m"),
execution_context=_exec_ctx(), execution_context=_exec_ctx(),
backend_binding_ref="binding-ref-1",
user_prompt=" ", user_prompt=" ",
) )
@@ -73,6 +75,7 @@ class TestBuildForAgentApp:
AgentBackendAgentAppRunInput( AgentBackendAgentAppRunInput(
model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
execution_context=_exec_ctx(), execution_context=_exec_ctx(),
backend_binding_ref="binding-ref-1",
user_prompt="hi", user_prompt="hi",
) )
) )
@@ -142,7 +145,6 @@ def _ctx(
*, *,
query: str = "hello", query: str = "hello",
agent_config_version_kind: str = "snapshot", agent_config_version_kind: str = "snapshot",
suspend_on_exit: bool = True,
) -> AgentAppRuntimeBuildContext: ) -> AgentAppRuntimeBuildContext:
dify_context = SimpleNamespace( dify_context = SimpleNamespace(
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -159,8 +161,9 @@ def _ctx(
conversation_id="conv-1", conversation_id="conv-1",
user_query=query, user_query=query,
idempotency_key="msg-1", idempotency_key="msg-1",
binding_id="binding-1",
backend_binding_ref="binding-ref-1",
agent_config_version_kind=agent_config_version_kind, # type: ignore[arg-type] agent_config_version_kind=agent_config_version_kind, # type: ignore[arg-type]
suspend_on_exit=suspend_on_exit,
) )
@@ -191,6 +194,7 @@ class TestAgentAppRuntimeRequestBuilder:
"agent_soul_prompt", "agent_soul_prompt",
"agent_app_user_prompt", "agent_app_user_prompt",
"execution_context", "execution_context",
"runtime",
DIFY_SHELL_LAYER_ID, DIFY_SHELL_LAYER_ID,
DIFY_CONFIG_LAYER_ID, DIFY_CONFIG_LAYER_ID,
"history", "history",
@@ -243,16 +247,6 @@ class TestAgentAppRuntimeRequestBuilder:
assert execution_context.config.agent_config_version_kind == "draft" assert execution_context.config.agent_config_version_kind == "draft"
assert config_layer.config.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): def test_build_includes_plugin_tools_layer_returned_by_injected_builder_for_draft(self):
soul = _soul_with_model() soul = _soul_with_model()
soul.tools.dify_tools = [ soul.tools.dify_tools = [
@@ -410,7 +404,7 @@ class TestAgentAppRuntimeRequestBuilder:
] ]
assert shell_config["cli_tools"][0]["install_commands"] == ["apt-get install -y ripgrep"] assert shell_config["cli_tools"][0]["install_commands"] == ["apt-get install -y ripgrep"]
assert shell_config["env"][0] == {"name": "PROJECT_NAME", "value": "demo"} assert shell_config["env"][0] == {"name": "PROJECT_NAME", "value": "demo"}
assert shell_config["sandbox"] == {"provider": "independent", "config": {"cpu": 2}} assert "sandbox" not in shell_config
assert result.metadata["agent_tools"] == { assert result.metadata["agent_tools"] == {
"dify_tool_count": 0, "dify_tool_count": 0,
"dify_tool_names": [], "dify_tool_names": [],
@@ -461,7 +455,7 @@ class TestAgentAppConfigLayer:
assert config.config.mentioned_file_names == [] assert config.config.mentioned_file_names == []
# shell enters first; config uses that shell to materialize mentioned targets. # shell enters first; config uses that shell to materialize mentioned targets.
names = [layer.name for layer in result.request.composition.layers] names = [layer.name for layer in result.request.composition.layers]
assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 1 assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 2
assert names.index(DIFY_CONFIG_LAYER_ID) == names.index(DIFY_SHELL_LAYER_ID) + 1 assert names.index(DIFY_CONFIG_LAYER_ID) == names.index(DIFY_SHELL_LAYER_ID) + 1
def test_config_layer_present_when_agent_soul_has_no_config_assets(self, monkeypatch: pytest.MonkeyPatch): def test_config_layer_present_when_agent_soul_has_no_config_assets(self, monkeypatch: pytest.MonkeyPatch):
@@ -484,7 +478,10 @@ class TestAgentAppConfigLayer:
"mentioned_skill_names": [], "mentioned_skill_names": [],
"mentioned_file_names": [], "mentioned_file_names": [],
} }
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": "execution_context"} assert layers[DIFY_SHELL_LAYER_ID].deps == {
"execution_context": "execution_context",
"runtime": "runtime",
}
assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref is None assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref is None
def test_config_layer_for_build_draft_marks_config_writable(self): def test_config_layer_for_build_draft_marks_config_writable(self):
@@ -1,348 +1,123 @@
"""Unit tests for the conversation-keyed Agent App session store. from types import SimpleNamespace
from unittest.mock import MagicMock
Exercises the real ORM round-trip against the project's in-memory SQLite engine
(per-test create/drop of the unified ``agent_runtime_sessions`` table), so the
conversation owner path is verified without Postgres.
"""
from __future__ import annotations
from collections.abc import Generator
import pytest import pytest
from agenton.compositor import CompositorSessionSnapshot from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from dify_agent.protocol import RuntimeLayerSpec
from sqlalchemy import delete
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope from core.app.apps.agent_app.session_store import AgentAppSessionScope, AgentAppWorkspaceStore
from core.db.session_factory import session_factory from models.agent import (
from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus AgentConfigVersionKind,
AgentWorkspaceOwnerType,
)
from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentWorkspaceService
def _scope( def _scope(
conversation_id: str = "conv-1", agent_id: str = "agent-1", agent_config_snapshot_id: str | None = "snap-1" *,
kind: AgentConfigVersionKind = AgentConfigVersionKind.SNAPSHOT,
build_draft_id: str | None = None,
) -> AgentAppSessionScope: ) -> AgentAppSessionScope:
return AgentAppSessionScope( return AgentAppSessionScope(
tenant_id="tenant-1", tenant_id="tenant-1",
app_id="app-1", app_id="app-1",
conversation_id=conversation_id, conversation_id="conversation-1",
agent_id=agent_id, agent_id="agent-1",
agent_config_snapshot_id=agent_config_snapshot_id, agent_config_snapshot_id="config-1",
home_snapshot_id="home-1",
agent_config_version_kind=kind,
build_draft_id=build_draft_id,
) )
def _snapshot(messages: int = 1) -> CompositorSessionSnapshot: def _binding() -> SimpleNamespace:
return CompositorSessionSnapshot( return SimpleNamespace(
layers=[ id="binding-1",
LayerSessionSnapshot( workspace_id="workspace-1",
name="history", backend_binding_ref="backend-binding-1",
lifecycle_state=LifecycleState.SUSPENDED, agent_id="agent-1",
runtime_state={"messages": [{"role": "user", "content": f"m{i}"} for i in range(messages)]}, agent_config_version_id="config-1",
) agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
] base_home_snapshot_id="home-1",
session_snapshot=None,
pending_form_id=None,
pending_tool_call_id=None,
) )
def _runtime_layer_specs() -> list[RuntimeLayerSpec]: def test_scope_selects_conversation_or_build_draft_workspace_owner() -> None:
return [ assert _scope().workspace_owner.owner_type is AgentWorkspaceOwnerType.CONVERSATION
RuntimeLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "tenant-1"}), build_owner = _scope(
RuntimeLayerSpec(name="history", type="pydantic_ai.history"), kind=AgentConfigVersionKind.BUILD_DRAFT,
] build_draft_id="build-draft-1",
).workspace_owner
assert build_owner.owner_type is AgentWorkspaceOwnerType.BUILD_DRAFT
assert build_owner.owner_id == "build-draft-1"
@pytest.fixture(autouse=True) def test_load_or_create_persists_new_binding_on_caller(monkeypatch) -> None:
def _create_table() -> Generator[None, None, None]: caller = SimpleNamespace(agent_workspace_binding_id=None)
engine = session_factory.get_session_maker().kw["bind"] context = MagicMock()
AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True) session = context.__enter__.return_value
yield create = MagicMock(return_value=_binding())
with session_factory.create_session() as session: store = AgentAppWorkspaceStore()
session.execute(delete(AgentRuntimeSession)) monkeypatch.setattr("core.app.apps.agent_app.session_store.session_factory.create_session", lambda: context)
session.commit() monkeypatch.setattr(store, "_load_caller", MagicMock(return_value=caller))
AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True) monkeypatch.setattr(AgentWorkspaceService, "create_binding", create)
stored = store.load_or_create(_scope())
assert stored.binding_id == "binding-1"
assert stored.workspace_id == "workspace-1"
assert stored.backend_binding_ref == "backend-binding-1"
assert caller.agent_workspace_binding_id == "binding-1"
assert create.call_args.kwargs["session"] is session
session.commit.assert_called_once_with()
def test_load_returns_none_when_no_row(): def test_load_or_create_uses_exact_caller_binding(monkeypatch) -> None:
assert AgentAppRuntimeSessionStore().load_active_snapshot(_scope()) is None caller = SimpleNamespace(agent_workspace_binding_id="binding-1")
context = MagicMock()
context.__enter__.return_value = MagicMock()
get_binding = MagicMock(return_value=_binding())
create = MagicMock()
store = AgentAppWorkspaceStore()
monkeypatch.setattr("core.app.apps.agent_app.session_store.session_factory.create_session", lambda: context)
monkeypatch.setattr(store, "_load_caller", MagicMock(return_value=caller))
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", MagicMock())
monkeypatch.setattr(AgentWorkspaceService, "create_binding", create)
stored = store.load_or_create(_scope())
assert stored.binding_id == "binding-1"
assert get_binding.call_args.kwargs["binding_id"] == "binding-1"
create.assert_not_called()
def test_save_creates_conversation_owned_row_and_round_trips(): def test_normal_conversation_pointer_does_not_create_replacement_binding(monkeypatch) -> None:
store = AgentAppRuntimeSessionStore() caller = SimpleNamespace(agent_workspace_binding_id="unavailable-binding")
store.save_active_snapshot( context = MagicMock()
scope=_scope(), get_binding = MagicMock(return_value=None)
backend_run_id="run-1", create = MagicMock()
snapshot=_snapshot(messages=2), store = AgentAppWorkspaceStore()
runtime_layer_specs=_runtime_layer_specs(), monkeypatch.setattr("core.app.apps.agent_app.session_store.session_factory.create_session", lambda: context)
) monkeypatch.setattr(store, "_load_caller", MagicMock(return_value=caller))
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
monkeypatch.setattr(AgentWorkspaceService, "create_binding", create)
loaded = store.load_active_snapshot(_scope()) with pytest.raises(AgentWorkspaceNotFoundError, match="Caller participant Binding is unavailable"):
assert loaded is not None store.load_or_create(_scope())
assert loaded.layers[0].runtime_state["messages"] == [
{"role": "user", "content": "m0"}, assert get_binding.call_args.kwargs["binding_id"] == "unavailable-binding"
{"role": "user", "content": "m1"}, create.assert_not_called()
]
with session_factory.create_session() as session:
row = session.query(AgentRuntimeSession).one()
assert row.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION
assert row.conversation_id == "conv-1"
assert row.agent_config_snapshot_id == "snap-1"
assert row.workflow_run_id is None # conversation owner leaves workflow cols NULL
assert row.backend_run_id == "run-1"
assert "execution_context" in row.composition_layer_specs
assert "history" in row.composition_layer_specs
def test_save_is_noop_when_snapshot_missing(): def test_save_snapshot_targets_binding(monkeypatch) -> None:
store = AgentAppRuntimeSessionStore() save = MagicMock()
store.save_active_snapshot( monkeypatch.setattr(AgentWorkspaceService, "save_binding_session_snapshot", save)
scope=_scope(), snapshot = CompositorSessionSnapshot(layers=[])
backend_run_id="run-x",
snapshot=None,
runtime_layer_specs=_runtime_layer_specs(),
)
with session_factory.create_session() as session:
assert session.query(AgentRuntimeSession).count() == 0
AgentAppWorkspaceStore().save_active_snapshot(scope=_scope(), binding_id="binding-1", snapshot=snapshot)
def test_second_turn_updates_same_conversation_row(): assert save.call_args.kwargs["binding_id"] == "binding-1"
store = AgentAppRuntimeSessionStore() assert save.call_args.kwargs["session_snapshot"] == snapshot.model_dump_json()
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(messages=1),
runtime_layer_specs=_runtime_layer_specs(),
)
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-2",
snapshot=_snapshot(messages=3),
runtime_layer_specs=_runtime_layer_specs(),
)
with session_factory.create_session() as session:
rows = session.query(AgentRuntimeSession).all()
assert len(rows) == 1
assert rows[0].backend_run_id == "run-2"
def test_debug_scope_with_null_snapshot_id_updates_same_conversation_row():
store = AgentAppRuntimeSessionStore()
scope = _scope(agent_config_snapshot_id=None)
store.save_active_snapshot(
scope=scope,
backend_run_id="run-1",
snapshot=_snapshot(messages=1),
runtime_layer_specs=_runtime_layer_specs(),
)
store.save_active_snapshot(
scope=scope,
backend_run_id="run-2",
snapshot=_snapshot(messages=3),
runtime_layer_specs=_runtime_layer_specs(),
)
loaded = store.load_active_snapshot(scope)
assert loaded is not None
assert loaded.layers[0].runtime_state["messages"] == [
{"role": "user", "content": "m0"},
{"role": "user", "content": "m1"},
{"role": "user", "content": "m2"},
]
with session_factory.create_session() as session:
row = session.query(AgentRuntimeSession).one()
assert row.agent_config_snapshot_id is None
assert row.backend_run_id == "run-2"
def test_mark_cleaned_then_load_returns_none_and_save_resurrects():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1")
assert store.load_active_snapshot(_scope()) is None
# Re-entry revives the row.
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-2",
snapshot=_snapshot(messages=2),
runtime_layer_specs=_runtime_layer_specs(),
)
with session_factory.create_session() as session:
row = session.query(AgentRuntimeSession).one()
assert row.status == AgentRuntimeSessionStatus.ACTIVE
assert row.cleaned_at is None
assert row.backend_run_id == "run-2"
def test_distinct_conversations_do_not_collide():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(
scope=_scope(conversation_id="conv-A"),
backend_run_id="a",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
store.save_active_snapshot(
scope=_scope(conversation_id="conv-B"),
backend_run_id="b",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
assert store.load_active_snapshot(_scope(conversation_id="conv-A")) is not None
assert store.load_active_snapshot(_scope(conversation_id="conv-B")) is not None
with session_factory.create_session() as session:
assert session.query(AgentRuntimeSession).count() == 2
def test_distinct_agent_config_snapshots_keep_only_latest_active_session():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(
scope=_scope(agent_config_snapshot_id="snap-1"),
backend_run_id="a",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
store.save_active_snapshot(
scope=_scope(agent_config_snapshot_id="snap-2"),
backend_run_id="b",
snapshot=_snapshot(messages=2),
runtime_layer_specs=_runtime_layer_specs(),
)
assert store.load_active_snapshot(_scope(agent_config_snapshot_id="snap-1")) is None
assert store.load_active_snapshot(_scope(agent_config_snapshot_id="snap-2")) is not None
with session_factory.create_session() as session:
rows = session.query(AgentRuntimeSession).order_by(AgentRuntimeSession.backend_run_id).all()
assert len(rows) == 2
assert [row.agent_config_snapshot_id for row in rows] == ["snap-1", "snap-2"]
assert [row.status for row in rows] == [AgentRuntimeSessionStatus.CLEANED, AgentRuntimeSessionStatus.ACTIVE]
def test_load_active_session_for_conversation_resolves_without_agent_or_config_scope():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(messages=2),
runtime_layer_specs=_runtime_layer_specs(),
)
loaded = store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
assert loaded is not None
assert loaded.session_snapshot.layers[0].runtime_state["messages"] == [
{"role": "user", "content": "m0"},
{"role": "user", "content": "m1"},
]
assert [spec.name for spec in loaded.runtime_layer_specs] == ["execution_context", "history"]
def test_load_active_session_for_conversation_uses_latest_active_snapshot_after_config_change():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(
scope=_scope(agent_config_snapshot_id="snap-1"),
backend_run_id="a",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
store.save_active_snapshot(
scope=_scope(agent_config_snapshot_id="snap-2"),
backend_run_id="b",
snapshot=_snapshot(messages=3),
runtime_layer_specs=_runtime_layer_specs(),
)
loaded = store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
assert loaded is not None
assert loaded.session_snapshot.layers[0].runtime_state["messages"] == [
{"role": "user", "content": "m0"},
{"role": "user", "content": "m1"},
{"role": "user", "content": "m2"},
]
def test_load_active_session_for_conversation_returns_none_when_cleaned_or_absent():
store = AgentAppRuntimeSessionStore()
assert (
store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
is None
)
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1")
assert (
store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
is None
)
def test_load_active_session_for_conversation_isolates_other_conversations():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(
scope=_scope(conversation_id="conv-A"),
backend_run_id="a",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
assert (
store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-B")
is None
)
assert (
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)
@@ -34,6 +34,7 @@ def test_update_node_execution_prefers_event_finished_at(monkeypatch: pytest.Mon
node_execution = Mock() node_execution = Mock()
node_execution.id = "node-exec-1" node_execution.id = "node-exec-1"
node_execution.created_at = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC).replace(tzinfo=None) node_execution.created_at = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC).replace(tzinfo=None)
node_execution.process_data = None
node_execution.update_from_mapping = Mock() node_execution.update_from_mapping = Mock()
layer._node_snapshots[node_execution.id] = _NodeRuntimeSnapshot( layer._node_snapshots[node_execution.id] = _NodeRuntimeSnapshot(
@@ -66,6 +67,7 @@ def test_update_node_execution_projects_start_outputs() -> None:
node_execution.id = "node-exec-2" node_execution.id = "node-exec-2"
node_execution.node_type = BuiltinNodeTypes.START node_execution.node_type = BuiltinNodeTypes.START
node_execution.created_at = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC).replace(tzinfo=None) node_execution.created_at = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC).replace(tzinfo=None)
node_execution.process_data = None
node_execution.update_from_mapping = Mock() node_execution.update_from_mapping = Mock()
layer._node_snapshots[node_execution.id] = _NodeRuntimeSnapshot( layer._node_snapshots[node_execution.id] = _NodeRuntimeSnapshot(
@@ -39,11 +39,15 @@ from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper,
class _RepoRecorder: class _RepoRecorder:
def __init__(self) -> None: def __init__(self) -> None:
self.saved: list[object] = [] self.saved: list[object] = []
self.synchronously_saved: list[object] = []
self.saved_exec_data: list[object] = [] self.saved_exec_data: list[object] = []
def save(self, entity): def save(self, entity):
self.saved.append(entity) self.saved.append(entity)
def save_synchronously(self, entity):
self.synchronously_saved.append(entity)
def save_execution_data(self, entity): def save_execution_data(self, entity):
self.saved_exec_data.append(entity) self.saved_exec_data.append(entity)
@@ -331,6 +335,24 @@ class TestWorkflowPersistenceLayer:
layer._handle_node_retry(retry_event) layer._handle_node_retry(retry_event)
assert node_repo.saved_exec_data assert node_repo.saved_exec_data
def test_agent_v2_caller_row_is_saved_synchronously_before_node_run(self):
layer, _, node_repo, _ = _make_layer()
layer._handle_graph_run_started()
layer._handle_node_started(
NodeRunStartedEvent(
id="agent-exec",
node_id="agent-node",
node_type=BuiltinNodeTypes.AGENT,
node_version="2",
node_title="Agent",
start_at=_naive_utc_now(),
)
)
assert [execution.id for execution in node_repo.synchronously_saved] == ["agent-exec"]
assert node_repo.saved == []
def test_retry_history_is_preserved_after_node_succeeds(self): def test_retry_history_is_preserved_after_node_succeeds(self):
layer, _, node_repo, _ = _make_layer() layer, _, node_repo, _ = _make_layer()
layer._handle_graph_run_started() layer._handle_graph_run_started()
@@ -501,7 +523,12 @@ class TestWorkflowPersistenceLayer:
domain_execution = layer._node_execution_cache["exec"] domain_execution = layer._node_execution_cache["exec"]
domain_execution.inputs = {"old": True} domain_execution.inputs = {"old": True}
result = NodeRunResult(inputs={"new": True}, outputs={"out": 1}, process_data={"p": 1}, metadata={}) result = NodeRunResult(
inputs={"new": True},
outputs={"out": 1},
process_data={"p": 1, "workflow_agent_binding_id": "workflow-binding-1"},
metadata={},
)
pause_event = NodeRunPauseRequestedEvent( pause_event = NodeRunPauseRequestedEvent(
id="exec", id="exec",
node_id="node", node_id="node",
@@ -513,6 +540,38 @@ class TestWorkflowPersistenceLayer:
assert domain_execution.status == WorkflowNodeExecutionStatus.PAUSED assert domain_execution.status == WorkflowNodeExecutionStatus.PAUSED
assert domain_execution.inputs == {"old": True} assert domain_execution.inputs == {"old": True}
assert domain_execution.process_data == {"workflow_agent_binding_id": "workflow-binding-1"}
def test_handle_node_retry_preserves_workflow_agent_binding_identity(self):
layer, _, _, _ = _make_layer()
layer._handle_graph_run_started()
started_at = _naive_utc_now()
layer._handle_node_started(
NodeRunStartedEvent(
id="exec",
node_id="node",
node_type=BuiltinNodeTypes.AGENT,
node_title="Agent",
start_at=started_at,
)
)
layer._handle_node_retry(
NodeRunRetryEvent(
id="exec",
node_id="node",
node_type=BuiltinNodeTypes.AGENT,
node_title="Agent",
start_at=started_at,
error="retry",
retry_index=1,
node_run_result=NodeRunResult(
process_data={"workflow_agent_binding_id": "workflow-binding-1"},
),
)
)
assert layer._node_execution_cache["exec"].process_data["workflow_agent_binding_id"] == "workflow-binding-1"
def test_get_node_execution_raises_for_missing(self): def test_get_node_execution_raises_for_missing(self):
layer, _, _, _ = _make_layer() layer, _, _, _ = _make_layer()
@@ -186,6 +186,23 @@ class TestCeleryWorkflowNodeExecutionRepository:
in repo._workflow_execution_mapping[sample_workflow_node_execution.workflow_execution_id] in repo._workflow_execution_mapping[sample_workflow_node_execution.workflow_execution_id]
) )
def test_save_synchronously_uses_sql_repository_without_queueing(
self, mock_session_factory, mock_account, sample_workflow_node_execution
):
repo = CeleryWorkflowNodeExecutionRepository(
session_factory=mock_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
app_id="test-app",
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
)
repo._sql_repository.save_synchronously = Mock()
repo.save_synchronously(sample_workflow_node_execution)
repo._sql_repository.save_synchronously.assert_called_once_with(sample_workflow_node_execution)
assert repo._execution_cache[sample_workflow_node_execution.id] is sample_workflow_node_execution
@patch("core.repositories.celery_workflow_node_execution_repository.save_workflow_node_execution_task") @patch("core.repositories.celery_workflow_node_execution_repository.save_workflow_node_execution_task")
def test_save_handles_celery_failure( def test_save_handles_celery_failure(
self, mock_task, mock_session_factory, mock_account, sample_workflow_node_execution self, mock_task, mock_session_factory, mock_account, sample_workflow_node_execution
@@ -238,7 +238,11 @@ def test_to_db_model_requires_constructor_context(monkeypatch: pytest.MonkeyPatc
app_id=None, app_id=None,
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
) )
execution = _execution(inputs={"b": 1, "a": 2}, metadata={WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: 1}) execution = _execution(
inputs={"b": 1, "a": 2},
process_data={"agent_workspace_binding_id": "participant-1"},
metadata={WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: 1},
)
# Happy path: deterministic json dump should be sorted # Happy path: deterministic json dump should be sorted
db_model = repo._to_db_model(execution) db_model = repo._to_db_model(execution)
@@ -247,6 +251,7 @@ def test_to_db_model_requires_constructor_context(monkeypatch: pytest.MonkeyPatc
assert db_model.created_by_role.value == "account" assert db_model.created_by_role.value == "account"
assert json.loads(db_model.inputs or "{}") == {"a": 2, "b": 1} assert json.loads(db_model.inputs or "{}") == {"a": 2, "b": 1}
assert json.loads(db_model.execution_metadata or "{}")["total_tokens"] == 1 assert json.loads(db_model.execution_metadata or "{}")["total_tokens"] == 1
assert db_model.agent_workspace_binding_id is None
repo._triggered_from = None repo._triggered_from = None
with pytest.raises(ValueError, match="triggered_from is required"): with pytest.raises(ValueError, match="triggered_from is required"):
@@ -330,7 +335,7 @@ def test_persist_to_database_updates_existing_and_inserts_new(monkeypatch: pytes
db_model.foo = "bar" # type: ignore[attr-defined] db_model.foo = "bar" # type: ignore[attr-defined]
db_model.__dict__["_private"] = "x" db_model.__dict__["_private"] = "x"
existing = SimpleNamespace() existing = SimpleNamespace(process_data=None, process_data_dict=None)
session.get.return_value = existing session.get.return_value = existing
repo._persist_to_database(db_model) repo._persist_to_database(db_model)
assert existing.foo == "bar" assert existing.foo == "bar"
@@ -523,7 +528,9 @@ def test_save_execution_data_handles_existing_db_model_and_truncation(monkeypatc
offload_data=[WorkflowNodeExecutionOffload(type_=ExecutionOffLoadType.INPUTS)], offload_data=[WorkflowNodeExecutionOffload(type_=ExecutionOffLoadType.INPUTS)],
inputs=None, inputs=None,
outputs=None, outputs=None,
process_data=None, process_data='{"workflow_agent_binding_id": "workflow-binding-1"}',
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
agent_workspace_binding_id="authoritative-participant",
) )
session.merge = Mock() session.merge = Mock()
session.flush = Mock() session.flush = Mock()
@@ -538,7 +545,11 @@ def test_save_execution_data_handles_existing_db_model_and_truncation(monkeypatc
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
) )
execution = _execution(inputs={"a": 1}, outputs={"b": 2}, process_data={"c": 3}) execution = _execution(
inputs={"a": 1},
outputs={"b": 2},
process_data={"c": 3},
)
trunc_result = SimpleNamespace( trunc_result = SimpleNamespace(
truncated_value={"trunc": True}, truncated_value={"trunc": True},
@@ -554,7 +565,11 @@ def test_save_execution_data_handles_existing_db_model_and_truncation(monkeypatc
db_model = session.merge.call_args.args[0] db_model = session.merge.call_args.args[0]
assert json.loads(db_model.inputs) == {"trunc": True} assert json.loads(db_model.inputs) == {"trunc": True}
assert json.loads(db_model.outputs) == {"b": 2} assert json.loads(db_model.outputs) == {"b": 2}
assert json.loads(db_model.process_data) == {"c": 3} assert json.loads(db_model.process_data) == {
"c": 3,
"workflow_agent_binding_id": "workflow-binding-1",
}
assert db_model.agent_workspace_binding_id == "authoritative-participant"
assert any(off.type_ == ExecutionOffLoadType.INPUTS for off in db_model.offload_data) assert any(off.type_ == ExecutionOffLoadType.INPUTS for off in db_model.offload_data)
assert execution.get_truncated_inputs() == {"trunc": True} assert execution.get_truncated_inputs() == {"trunc": True}
@@ -570,6 +585,7 @@ def test_save_execution_data_truncates_outputs_and_process_data(monkeypatch: pyt
inputs=None, inputs=None,
outputs=None, outputs=None,
process_data=None, process_data=None,
process_data_dict=None,
) )
session = MagicMock() session = MagicMock()
session.execute.return_value.scalars.return_value.first.return_value = existing session.execute.return_value.scalars.return_value.first.return_value = existing
@@ -633,7 +649,14 @@ def test_save_execution_data_handles_missing_db_model(monkeypatch: pytest.Monkey
) )
execution = _execution(inputs={"a": 1}) execution = _execution(inputs={"a": 1})
fake_db_model = SimpleNamespace(id=execution.id, offload_data=[], inputs=None, outputs=None, process_data=None) fake_db_model = SimpleNamespace(
id=execution.id,
offload_data=[],
inputs=None,
outputs=None,
process_data=None,
process_data_dict=None,
)
monkeypatch.setattr(repo, "_to_db_model", lambda *_: fake_db_model) monkeypatch.setattr(repo, "_to_db_model", lambda *_: fake_db_model)
monkeypatch.setattr(repo, "_truncate_and_upload", lambda *_args, **_kwargs: None) monkeypatch.setattr(repo, "_truncate_and_upload", lambda *_args, **_kwargs: None)
monkeypatch.setattr(repo, "_json_encode", lambda values: json.dumps(values)) monkeypatch.setattr(repo, "_json_encode", lambda values: json.dumps(values))
@@ -99,6 +99,7 @@ class TestWorkflowNodeExecutionConflictHandling:
# Mock existing record # Mock existing record
mock_existing = MagicMock() mock_existing = MagicMock()
mock_existing.process_data_dict = None
mock_session.get.return_value = mock_existing mock_session.get.return_value = mock_existing
mock_session.commit.return_value = None mock_session.commit.return_value = None

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