Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb01984ed | ||
|
|
77e41a37ee | ||
|
|
79229b7ede | ||
|
|
36ae648a63 | ||
|
|
c409d531da | ||
|
|
511ca69e4e | ||
|
|
4c7e60d7e7 | ||
|
|
a9e163403d | ||
|
|
bc136b89f9 | ||
|
|
b1bd4f9a8b | ||
|
|
06d3927f05 | ||
|
|
5c3516cae8 | ||
|
|
1c18d8ddbd | ||
|
|
253dfe9351 | ||
|
|
2d3999e984 | ||
|
|
a431cc726d | ||
|
|
08493d2429 | ||
|
|
8dd0969006 | ||
|
|
2d80d3c35c | ||
|
|
4da764904c | ||
|
|
5c4f4fd1ef | ||
|
|
54843971ac | ||
|
|
5ec5d3aeb9 | ||
|
|
d661b53e49 | ||
|
|
fd1e777f85 | ||
|
|
e5f77ce185 | ||
|
|
99929d1c16 | ||
|
|
c699aa11db | ||
|
|
06cd0b56ac | ||
|
|
241a9e1fec | ||
|
|
3d79689fb5 | ||
|
|
9f050f7957 | ||
|
|
cb691d47d3 | ||
|
|
04f5ee58d0 | ||
|
|
f5cff724f4 | ||
|
|
1f4ccafdea | ||
|
|
163049f6ca | ||
|
|
65e7507ca3 | ||
|
|
ba157f9604 | ||
|
|
0dc913630e | ||
|
|
875cd30b1f | ||
|
|
aa4a32ae84 | ||
|
|
8573e14777 | ||
|
|
1855be234c | ||
|
|
9bb960ff12 | ||
|
|
1c14c7d467 | ||
|
|
61faec16ca | ||
|
|
57c836e692 | ||
|
|
626cc282b1 | ||
|
|
52624d54e3 | ||
|
|
5ce038ef92 | ||
|
|
30f4d4c0c6 | ||
|
|
510679a7d1 | ||
|
|
9237f2a14a | ||
|
|
bd178c7b29 | ||
|
|
d80947aa72 | ||
|
|
1618c37d26 | ||
|
|
701ab64462 | ||
|
|
b3298800e9 | ||
|
|
0f1c6b3f78 | ||
|
|
9b4b246aad | ||
|
|
1bd654a289 | ||
|
|
fc70329bdb | ||
|
|
cd8a82fbd4 | ||
|
|
550cb7eff5 | ||
|
|
2e748c16e9 | ||
|
|
577012b66d | ||
|
|
251c324180 | ||
|
|
865a618fd5 | ||
|
|
991116990a | ||
|
|
eb5d1da0e8 | ||
|
|
dce3b7a7fc | ||
|
|
be386aba3b | ||
|
|
02e51e7d7c | ||
|
|
96b6d4f2c0 | ||
|
|
4c84c5957d | ||
|
|
34613ecdc5 | ||
|
|
6a14245401 | ||
|
|
a758ca2aef | ||
|
|
9e60d4e213 | ||
|
|
ef29c8442c | ||
|
|
a1b45415ac | ||
|
|
7fc46d75bd | ||
|
|
953a4ef0ca |
@@ -1,7 +1,6 @@
|
|||||||
name: Deploy Knowledge
|
name: Deploy Knowledge
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -19,48 +18,6 @@ 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:
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ 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
@@ -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 -m nltk.downloader punkt_tab averaged_perceptron_tagger_eng stopwords \
|
&& 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 -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
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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,
|
||||||
@@ -45,6 +47,11 @@ 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",
|
||||||
@@ -73,6 +80,8 @@ __all__ = [
|
|||||||
"AgentBackendRunRequestBuilder",
|
"AgentBackendRunRequestBuilder",
|
||||||
"AgentBackendRunStartedInternalEvent",
|
"AgentBackendRunStartedInternalEvent",
|
||||||
"AgentBackendRunSucceededInternalEvent",
|
"AgentBackendRunSucceededInternalEvent",
|
||||||
|
"AgentBackendSessionCleanupPayload",
|
||||||
|
"AgentBackendSessionCleanupResult",
|
||||||
"AgentBackendStreamError",
|
"AgentBackendStreamError",
|
||||||
"AgentBackendStreamInternalEvent",
|
"AgentBackendStreamInternalEvent",
|
||||||
"AgentBackendTransportError",
|
"AgentBackendTransportError",
|
||||||
@@ -81,6 +90,9 @@ __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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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
|
||||||
@@ -36,7 +37,6 @@ 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,6 +47,7 @@ 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
|
||||||
|
|
||||||
@@ -55,7 +56,6 @@ 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,11 +66,25 @@ 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 {
|
return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||||
"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]:
|
||||||
@@ -200,7 +214,6 @@ 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
|
||||||
@@ -218,8 +231,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 graph. Requires a deployment-selected runtime
|
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
|
||||||
# backend plus the product-resolved persistent Binding.
|
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
|
||||||
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
|
||||||
@@ -227,6 +240,7 @@ 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)
|
||||||
@@ -250,7 +264,6 @@ 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"
|
||||||
@@ -266,8 +279,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 graph. Requires a deployment-selected runtime
|
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
|
||||||
# backend plus the product-resolved persistent Binding.
|
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
|
||||||
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
|
||||||
@@ -275,6 +288,7 @@ 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)
|
||||||
@@ -336,14 +350,6 @@ 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.
|
||||||
@@ -475,7 +481,53 @@ 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(default=ExitIntent.SUSPEND),
|
on_exit=LayerExitSignals(
|
||||||
|
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:
|
||||||
@@ -528,14 +580,6 @@ 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.
|
||||||
@@ -669,7 +713,9 @@ 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(default=ExitIntent.SUSPEND),
|
on_exit=LayerExitSignals(
|
||||||
|
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Shared API-side helper for Agent backend lifecycle-only session cleanup.
|
||||||
|
|
||||||
|
Product code owns local row retirement and background-task dispatch. This module
|
||||||
|
only adapts persisted cleanup inputs into the public ``dify-agent`` run
|
||||||
|
protocol, performs the synchronous ``create_run + wait_run`` loop used by Celery
|
||||||
|
workers, and reports whether the backend cleanup succeeded, was skipped, or
|
||||||
|
failed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import ClassVar, Literal
|
||||||
|
|
||||||
|
from agenton.compositor import CompositorSessionSnapshot
|
||||||
|
from dify_agent.protocol import RuntimeLayerSpec
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
||||||
|
|
||||||
|
from clients.agent_backend.client import AgentBackendRunClient
|
||||||
|
from clients.agent_backend.errors import AgentBackendError
|
||||||
|
from clients.agent_backend.request_builder import AgentBackendRunRequestBuilder
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendSessionCleanupPayload(BaseModel):
|
||||||
|
"""Serialized cleanup inputs preserved across API and Celery boundaries."""
|
||||||
|
|
||||||
|
session_snapshot: CompositorSessionSnapshot | None = None
|
||||||
|
runtime_layer_specs: list[RuntimeLayerSpec] = Field(default_factory=list)
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||||
|
timeout_seconds: float = 30.0
|
||||||
|
|
||||||
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AgentBackendSessionCleanupResult:
|
||||||
|
"""Terminal outcome of one backend cleanup attempt."""
|
||||||
|
|
||||||
|
status: Literal["succeeded", "skipped", "failed"]
|
||||||
|
reason: str | None = None
|
||||||
|
cleanup_run_id: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def succeeded(cls, cleanup_run_id: str) -> AgentBackendSessionCleanupResult:
|
||||||
|
return cls(status="succeeded", cleanup_run_id=cleanup_run_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def skipped(cls, reason: str) -> AgentBackendSessionCleanupResult:
|
||||||
|
return cls(status="skipped", reason=reason)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def failed(cls, reason: str, cleanup_run_id: str | None = None) -> AgentBackendSessionCleanupResult:
|
||||||
|
return cls(status="failed", reason=reason, cleanup_run_id=cleanup_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_agent_backend_session(
|
||||||
|
*,
|
||||||
|
payload: AgentBackendSessionCleanupPayload,
|
||||||
|
client: AgentBackendRunClient | None,
|
||||||
|
request_builder: AgentBackendRunRequestBuilder | None = None,
|
||||||
|
) -> AgentBackendSessionCleanupResult:
|
||||||
|
"""Run lifecycle-only cleanup against the Agent backend and report status."""
|
||||||
|
if client is None:
|
||||||
|
return AgentBackendSessionCleanupResult.skipped("no_agent_backend_client")
|
||||||
|
if payload.session_snapshot is None:
|
||||||
|
return AgentBackendSessionCleanupResult.skipped("missing_session_snapshot")
|
||||||
|
if not payload.runtime_layer_specs:
|
||||||
|
return AgentBackendSessionCleanupResult.skipped("missing_runtime_layer_specs")
|
||||||
|
|
||||||
|
builder = request_builder or AgentBackendRunRequestBuilder()
|
||||||
|
request = builder.build_cleanup_request(
|
||||||
|
session_snapshot=payload.session_snapshot,
|
||||||
|
runtime_layer_specs=payload.runtime_layer_specs,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.create_run(request)
|
||||||
|
except AgentBackendError as exc:
|
||||||
|
return AgentBackendSessionCleanupResult.failed(str(exc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
status_response = client.wait_run(response.run_id, timeout_seconds=payload.timeout_seconds)
|
||||||
|
except AgentBackendError as exc:
|
||||||
|
return AgentBackendSessionCleanupResult.failed(str(exc), cleanup_run_id=response.run_id)
|
||||||
|
|
||||||
|
if status_response.status != "succeeded":
|
||||||
|
reason = status_response.error or f"cleanup run ended with status {status_response.status}"
|
||||||
|
return AgentBackendSessionCleanupResult.failed(reason, cleanup_run_id=response.run_id)
|
||||||
|
|
||||||
|
return AgentBackendSessionCleanupResult.succeeded(response.run_id)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentBackendSessionCleanupPayload",
|
||||||
|
"AgentBackendSessionCleanupResult",
|
||||||
|
"cleanup_agent_backend_session",
|
||||||
|
]
|
||||||
@@ -44,8 +44,9 @@ class AgentBackendConfig(BaseSettings):
|
|||||||
|
|
||||||
AGENT_SHELL_ENABLED: bool = Field(
|
AGENT_SHELL_ENABLED: bool = Field(
|
||||||
description=(
|
description=(
|
||||||
"Inject the Home, Workspace, Sandbox, and Shell runtime layers into Agent runs. "
|
"Inject the dify.shell layer (sandboxed bash workspace) into Agent runs. "
|
||||||
"Requires Dify Agent to have a deployment-selected runtime backend."
|
"Requires the agent backend to be wired with a shellctl entrypoint before "
|
||||||
|
"shell-using Agent runs are executed."
|
||||||
),
|
),
|
||||||
default=True,
|
default=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def with_session[T, **P, R](
|
|||||||
session.commit()
|
session.commit()
|
||||||
return result
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback
|
session.rollback() # noqa: no-new-controller-sqlalchemy decorator owns transaction rollback
|
||||||
raise
|
raise
|
||||||
|
|
||||||
with session_factory.create_session() as session:
|
with session_factory.create_session() as session:
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ from .workspace import (
|
|||||||
models,
|
models,
|
||||||
plugin,
|
plugin,
|
||||||
rbac,
|
rbac,
|
||||||
|
skills,
|
||||||
snippets,
|
snippets,
|
||||||
tool_providers,
|
tool_providers,
|
||||||
trigger_providers,
|
trigger_providers,
|
||||||
@@ -225,6 +226,7 @@ __all__ = [
|
|||||||
"saved_message",
|
"saved_message",
|
||||||
"setup",
|
"setup",
|
||||||
"site",
|
"site",
|
||||||
|
"skills",
|
||||||
"snippet_workflow",
|
"snippet_workflow",
|
||||||
"snippet_workflow_draft_variable",
|
"snippet_workflow_draft_variable",
|
||||||
"snippets",
|
"snippets",
|
||||||
|
|||||||
@@ -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, AgentStatus
|
from models.agent import Agent, AgentConfigDraftType, 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,6 +265,13 @@ 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")
|
||||||
|
|
||||||
@@ -308,6 +315,7 @@ register_schema_models(
|
|||||||
AgentAppCopyPayload,
|
AgentAppCopyPayload,
|
||||||
AgentPublishPayload,
|
AgentPublishPayload,
|
||||||
AgentBuildDraftCheckoutPayload,
|
AgentBuildDraftCheckoutPayload,
|
||||||
|
AgentDebugConversationRefreshPayload,
|
||||||
ComposerSavePayload,
|
ComposerSavePayload,
|
||||||
AgentApiStatusPayload,
|
AgentApiStatusPayload,
|
||||||
AgentInviteOptionsQuery,
|
AgentInviteOptionsQuery,
|
||||||
@@ -387,10 +395,11 @@ 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_build_conversation(
|
debug_conversation_id = roster_service.get_or_create_agent_app_debug_conversation_id(
|
||||||
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(
|
||||||
@@ -430,10 +439,11 @@ 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_build_conversation_ids_by_agent_id(
|
debug_conversation_ids_by_agent_id = roster_service.load_or_create_agent_app_debug_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,
|
||||||
@@ -668,6 +678,16 @@ 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",
|
||||||
@@ -682,10 +702,12 @@ 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):
|
||||||
debug_conversation_id = _agent_roster_service(session).reset_build_conversation(
|
args = AgentDebugConversationRefreshPayload.model_validate(request.get_json(silent=True) or {})
|
||||||
|
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,
|
||||||
@@ -729,7 +751,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(write=False)
|
@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 = 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(
|
||||||
@@ -786,7 +808,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(write=False)
|
@with_session
|
||||||
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,
|
||||||
@@ -806,7 +828,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(write=False)
|
@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):
|
||||||
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 accepts product-facing Conversation, Build Draft, or Workflow Node
|
The API keeps product-facing locators (conversation or workflow node identity)
|
||||||
Execution locators and proxies list/read/upload to the agent backend's
|
on this public boundary and proxies list/read/upload to the agent backend's new
|
||||||
``/sandbox`` contract.
|
``/sandbox`` contract.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -26,16 +26,10 @@ 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 (
|
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||||
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,
|
||||||
@@ -43,43 +37,52 @@ 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):
|
||||||
caller_type: Literal["conversation", "build_draft"]
|
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||||
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):
|
||||||
caller_type: Literal["conversation", "build_draft"]
|
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
|
||||||
|
|
||||||
|
|
||||||
class AgentSandboxFileQuery(BaseModel):
|
class AgentSandboxFileQuery(BaseModel):
|
||||||
caller_type: Literal["conversation", "build_draft"]
|
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||||
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):
|
||||||
caller_type: Literal["conversation", "build_draft"]
|
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||||
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):
|
||||||
@@ -96,6 +99,7 @@ class SandboxListResponse(ResponseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SandboxInfoResponse(ResponseModel):
|
class SandboxInfoResponse(ResponseModel):
|
||||||
|
session_id: str
|
||||||
workspace_cwd: str
|
workspace_cwd: str
|
||||||
|
|
||||||
|
|
||||||
@@ -151,19 +155,15 @@ 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, current_user: Account, tenant_id: str, agent_id: UUID):
|
def get(self, session: Session, 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,
|
||||||
agent_id=str(agent_id),
|
conversation_id=query.conversation_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,19 +180,15 @@ 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, current_user: Account, tenant_id: str, agent_id: UUID):
|
def get(self, session: Session, 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,
|
||||||
agent_id=str(agent_id),
|
conversation_id=query.conversation_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:
|
||||||
@@ -210,19 +206,15 @@ 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, current_user: Account, tenant_id: str, agent_id: UUID):
|
def get(self, session: Session, 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,
|
||||||
agent_id=str(agent_id),
|
conversation_id=query.conversation_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:
|
||||||
@@ -240,19 +232,15 @@ 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, current_user: Account, tenant_id: str, agent_id: UUID):
|
def post(self, session: Session, 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,
|
||||||
agent_id=str(agent_id),
|
conversation_id=payload.conversation_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:
|
||||||
|
|||||||
@@ -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 InvokeFrom
|
from core.app.entities.app_invoke_entities import AGENT_RUNTIME_EXIT_INTENT_ARG, InvokeFrom
|
||||||
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
|
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
|
||||||
from core.errors.error import (
|
from core.errors.error import (
|
||||||
ModelCurrentlyNotSupportError,
|
ModelCurrentlyNotSupportError,
|
||||||
@@ -353,7 +353,12 @@ 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 the current editor's Build or Preview conversation."""
|
"""Resolve or rotate the current editor's conversation within one draft surface.
|
||||||
|
|
||||||
|
``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
|
||||||
@@ -363,26 +368,17 @@ def _resolve_current_user_agent_debug_conversation_id(
|
|||||||
raise AgentNotFoundError()
|
raise AgentNotFoundError()
|
||||||
resolved_agent_id = agent.id
|
resolved_agent_id = agent.id
|
||||||
|
|
||||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
resolve_conversation = (
|
||||||
return roster_service.get_or_create_build_conversation(
|
roster_service.refresh_agent_app_debug_conversation_id
|
||||||
tenant_id=current_tenant_id,
|
if start_new
|
||||||
agent_id=resolved_agent_id,
|
else roster_service.get_or_create_agent_app_debug_conversation_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(
|
||||||
@@ -454,6 +450,7 @@ 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:
|
||||||
|
|||||||
@@ -76,13 +76,11 @@ 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__)
|
||||||
|
|
||||||
@@ -319,7 +317,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) # guard-ignore: no-new-getattr -- delegates model fields
|
return getattr(self._workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def created_by_account(self) -> Account | None:
|
def created_by_account(self) -> Account | None:
|
||||||
@@ -1247,7 +1245,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, retirement_candidates = workflow_service.publish_workflow(
|
workflow = workflow_service.publish_workflow(
|
||||||
session=session,
|
session=session,
|
||||||
app_model=app_model,
|
app_model=app_model,
|
||||||
account=current_user,
|
account=current_user,
|
||||||
@@ -1264,16 +1262,6 @@ 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,
|
||||||
|
|||||||
@@ -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) # guard-ignore: no-new-getattr -- delegates model fields
|
return getattr(self.query, name) # noqa: no-new-getattr response adapter 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) # guard-ignore: no-new-getattr -- delegates model fields
|
return getattr(self.app, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||||
|
|
||||||
|
|
||||||
class RelatedAppListResponse(ResponseModel):
|
class RelatedAppListResponse(ResponseModel):
|
||||||
|
|||||||
@@ -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) # guard-ignore: no-new-getattr -- delegates model fields
|
return getattr(self.external_knowledge_api, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||||
|
|
||||||
|
|
||||||
def external_knowledge_api_response(
|
def external_knowledge_api_response(
|
||||||
|
|||||||
@@ -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) # guard-ignore: no-new-getattr -- delegates model fields
|
return getattr(self.workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||||
|
|
||||||
|
|
||||||
register_schema_models(
|
register_schema_models(
|
||||||
|
|||||||
@@ -56,12 +56,10 @@ 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__)
|
||||||
|
|
||||||
@@ -297,9 +295,8 @@ 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, retirement_candidates = snippet_service.publish_workflow(
|
workflow = snippet_service.publish_workflow(
|
||||||
session=session,
|
session=session,
|
||||||
snippet=snippet,
|
snippet=snippet,
|
||||||
account=current_user,
|
account=current_user,
|
||||||
@@ -309,16 +306,6 @@ 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,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class TagBindingRemovePayload(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TagListQueryParam(BaseModel):
|
class TagListQueryParam(BaseModel):
|
||||||
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
|
type: TagType | Literal[""] = Field("", description="Tag type filter")
|
||||||
keyword: str | None = Field(None, description="Search keyword")
|
keyword: str | None = Field(None, description="Search keyword")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,834 @@
|
|||||||
|
"""Console API for workspace-level Skill Management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
from flask import request, send_file
|
||||||
|
from flask_restx import Resource
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||||
|
|
||||||
|
from controllers.common.fields import BinaryFileResponse
|
||||||
|
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||||
|
from controllers.console import console_ns
|
||||||
|
from controllers.console.wraps import (
|
||||||
|
account_initialization_required,
|
||||||
|
edit_permission_required,
|
||||||
|
setup_required,
|
||||||
|
with_current_tenant_id,
|
||||||
|
with_current_user,
|
||||||
|
)
|
||||||
|
from fields.base import ResponseModel
|
||||||
|
from libs import helper
|
||||||
|
from libs.helper import dump_response
|
||||||
|
from libs.login import login_required
|
||||||
|
from models.account import Account
|
||||||
|
from services.skill_management_service import (
|
||||||
|
SkillAssistMessagePayload,
|
||||||
|
SkillCreatePayload,
|
||||||
|
SkillDraftFileOperationPayload,
|
||||||
|
SkillDraftTreePayload,
|
||||||
|
SkillImportPayload,
|
||||||
|
SkillManagementService,
|
||||||
|
SkillManagementServiceError,
|
||||||
|
SkillMetadataPayload,
|
||||||
|
SkillPublishPayload,
|
||||||
|
SkillRestorePayload,
|
||||||
|
SkillVersionUpdatePayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
_FILE_UPLOAD_PARAMS = {
|
||||||
|
"file": {
|
||||||
|
"description": "Skill draft file payload",
|
||||||
|
"in": "formData",
|
||||||
|
"type": "file",
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceSkillsQuery(BaseModel):
|
||||||
|
keyword: str | None = Field(default=None, description="Search keyword matching skill name or description.")
|
||||||
|
page: int = Field(default=1, ge=1, le=99999, description="Page number.")
|
||||||
|
limit: int = Field(default=20, ge=1, le=100, description="Number of items per page.")
|
||||||
|
tag: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Skill tag filters. Repeat the parameter for multiple tags.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillDeletePayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
confirmation_name: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Required when deleting a referenced Skill. Must match the Skill name.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSkillBindingsPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
skill_ids: list[str] = Field(default_factory=list, description="Ordered Skill IDs bound to the Agent.")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillFileQuery(BaseModel):
|
||||||
|
path: str = Field(description="Skill file path relative to the Skill root.")
|
||||||
|
version_id: str | None = Field(default=None, description="Optional published version ID. Omit for current draft.")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
icon: str
|
||||||
|
description: str
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
name_manually_edited: bool = False
|
||||||
|
visibility: str
|
||||||
|
latest_published_version_id: str | None = None
|
||||||
|
reference_count: int = 0
|
||||||
|
created_by: str | None = None
|
||||||
|
created_by_name: str | None = None
|
||||||
|
updated_by: str | None = None
|
||||||
|
updated_by_name: str | None = None
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class SkillFileResponse(ResponseModel):
|
||||||
|
id: str | None = None
|
||||||
|
path: str
|
||||||
|
kind: str
|
||||||
|
storage: str | None = None
|
||||||
|
mime_type: str | None = None
|
||||||
|
content: str | None = None
|
||||||
|
tool_file_id: str | None = None
|
||||||
|
size: int | None = None
|
||||||
|
hash: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillFilePreviewResponse(ResponseModel):
|
||||||
|
path: str
|
||||||
|
mime_type: str
|
||||||
|
content: str
|
||||||
|
size: int
|
||||||
|
hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class SkillFileUploadResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
mime_type: str
|
||||||
|
size: int
|
||||||
|
hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class SkillDetailResponse(SkillResponse):
|
||||||
|
files: list[SkillFileResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillListResponse(ResponseModel):
|
||||||
|
data: list[SkillResponse] = Field(default_factory=list)
|
||||||
|
has_more: bool = False
|
||||||
|
limit: int = 20
|
||||||
|
page: int = 1
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SkillTagResponse(ResponseModel):
|
||||||
|
tag: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class SkillTagListResponse(ResponseModel):
|
||||||
|
data: list[SkillTagResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillVersionResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
skill_id: str
|
||||||
|
version_number: int
|
||||||
|
version_name: str
|
||||||
|
publish_note: str
|
||||||
|
hash_code: str
|
||||||
|
archive_size: int
|
||||||
|
published_by: str | None = None
|
||||||
|
published_by_name: str | None = None
|
||||||
|
is_latest: bool = False
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class SkillVersionListResponse(ResponseModel):
|
||||||
|
data: list[SkillVersionResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillVersionDetailResponse(SkillVersionResponse):
|
||||||
|
files: list[SkillFileResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillVersionDeleteResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
deleted: bool
|
||||||
|
latest_published_version_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillReferenceResponse(ResponseModel):
|
||||||
|
type: str
|
||||||
|
agent_id: str
|
||||||
|
agent_icon: str | None = None
|
||||||
|
agent_icon_background: str | None = None
|
||||||
|
agent_icon_type: str | None = None
|
||||||
|
app_id: str | None = None
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
workflow_id: str | None = None
|
||||||
|
workflow_name: str | None = None
|
||||||
|
workflow_icon: str | None = None
|
||||||
|
workflow_icon_background: str | None = None
|
||||||
|
workflow_icon_type: str | None = None
|
||||||
|
workflow_version: str | None = None
|
||||||
|
node_id: str | None = None
|
||||||
|
node_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillReferenceListResponse(ResponseModel):
|
||||||
|
data: list[SkillReferenceResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillDeleteResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
deleted: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSkillBindingItemResponse(ResponseModel):
|
||||||
|
id: str
|
||||||
|
priority: int
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
icon: str
|
||||||
|
description: str
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
status: str
|
||||||
|
file_count: int
|
||||||
|
latest_published_version_id: str | None = None
|
||||||
|
latest_published_at: int | None = None
|
||||||
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSkillBindingsResponse(ResponseModel):
|
||||||
|
agent_id: str
|
||||||
|
skill_ids: list[str] = Field(default_factory=list)
|
||||||
|
data: list[AgentSkillBindingItemResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
register_schema_models(
|
||||||
|
console_ns,
|
||||||
|
WorkspaceSkillsQuery,
|
||||||
|
SkillCreatePayload,
|
||||||
|
SkillAssistMessagePayload,
|
||||||
|
SkillMetadataPayload,
|
||||||
|
SkillDraftFileOperationPayload,
|
||||||
|
SkillDraftTreePayload,
|
||||||
|
SkillPublishPayload,
|
||||||
|
SkillRestorePayload,
|
||||||
|
SkillVersionUpdatePayload,
|
||||||
|
SkillDeletePayload,
|
||||||
|
SkillFileQuery,
|
||||||
|
AgentSkillBindingsPayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
register_response_schema_models(
|
||||||
|
console_ns,
|
||||||
|
SkillResponse,
|
||||||
|
SkillFileResponse,
|
||||||
|
SkillFilePreviewResponse,
|
||||||
|
SkillFileUploadResponse,
|
||||||
|
SkillDetailResponse,
|
||||||
|
SkillListResponse,
|
||||||
|
SkillTagResponse,
|
||||||
|
SkillTagListResponse,
|
||||||
|
SkillVersionResponse,
|
||||||
|
SkillVersionListResponse,
|
||||||
|
SkillVersionDetailResponse,
|
||||||
|
SkillVersionDeleteResponse,
|
||||||
|
SkillReferenceResponse,
|
||||||
|
SkillReferenceListResponse,
|
||||||
|
SkillDeleteResponse,
|
||||||
|
AgentSkillBindingItemResponse,
|
||||||
|
AgentSkillBindingsResponse,
|
||||||
|
BinaryFileResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, object], int]:
|
||||||
|
body: dict[str, object] = {"code": exc.code, "message": exc.message}
|
||||||
|
if exc.details:
|
||||||
|
body["details"] = exc.details
|
||||||
|
return body, exc.status_code
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills")
|
||||||
|
class WorkspaceSkillsApi(Resource):
|
||||||
|
@console_ns.doc(params=query_params_from_model(WorkspaceSkillsQuery))
|
||||||
|
@console_ns.response(200, "Workspace skills", console_ns.models[SkillListResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str):
|
||||||
|
query_input: dict[str, object] = {
|
||||||
|
"keyword": request.args.get("keyword"),
|
||||||
|
"tag": request.args.getlist("tag"),
|
||||||
|
}
|
||||||
|
if "limit" in request.args:
|
||||||
|
query_input["limit"] = request.args.get("limit")
|
||||||
|
if "page" in request.args:
|
||||||
|
query_input["page"] = request.args.get("page")
|
||||||
|
query = WorkspaceSkillsQuery.model_validate(query_input)
|
||||||
|
result = SkillManagementService().list_skills(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
keyword=query.keyword,
|
||||||
|
page=query.page,
|
||||||
|
limit=query.limit,
|
||||||
|
tags=[tag for tag in query.tag if tag],
|
||||||
|
)
|
||||||
|
return dump_response(SkillListResponse, result)
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[SkillCreatePayload.__name__])
|
||||||
|
@console_ns.response(201, "Skill created", console_ns.models[SkillDetailResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def post(self, current_tenant_id: str, current_user: Account):
|
||||||
|
try:
|
||||||
|
payload = SkillCreatePayload.model_validate(console_ns.payload or {})
|
||||||
|
result = SkillManagementService().create_skill(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return dump_response(SkillDetailResponse, result), 201
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/files/upload")
|
||||||
|
class WorkspaceSkillFileUploadApi(Resource):
|
||||||
|
@console_ns.doc(consumes=["multipart/form-data"], params=_FILE_UPLOAD_PARAMS)
|
||||||
|
@console_ns.response(201, "Skill draft file uploaded", console_ns.models[SkillFileUploadResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def post(self, current_tenant_id: str, current_user: Account):
|
||||||
|
if "file" not in request.files:
|
||||||
|
return {"code": "no_file_uploaded", "message": "no file uploaded"}, 400
|
||||||
|
|
||||||
|
file = request.files["file"]
|
||||||
|
if not file.filename:
|
||||||
|
return {"code": "filename_missing", "message": "filename is required"}, 400
|
||||||
|
|
||||||
|
result = SkillManagementService().upload_file(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
filename=file.filename,
|
||||||
|
content=file.stream.read(),
|
||||||
|
mime_type=file.mimetype,
|
||||||
|
)
|
||||||
|
return dump_response(SkillFileUploadResponse, result), 201
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/tags")
|
||||||
|
class WorkspaceSkillTagsApi(Resource):
|
||||||
|
@console_ns.response(200, "Workspace Skill tags", console_ns.models[SkillTagListResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str):
|
||||||
|
result = SkillManagementService().list_tags(tenant_id=current_tenant_id)
|
||||||
|
return dump_response(SkillTagListResponse, result)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/import")
|
||||||
|
class WorkspaceSkillImportApi(Resource):
|
||||||
|
@console_ns.doc(description="Import a Skill zip package from multipart form field `file`.")
|
||||||
|
@console_ns.response(201, "Skill imported", console_ns.models[SkillDetailResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def post(self, current_tenant_id: str, current_user: Account):
|
||||||
|
upload = request.files.get("file")
|
||||||
|
if upload is None:
|
||||||
|
return {"code": "invalid_request", "message": "file is required"}, 400
|
||||||
|
try:
|
||||||
|
payload = SkillImportPayload(content=upload.read(), filename=upload.filename or "skill.zip")
|
||||||
|
result = SkillManagementService().import_skill(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return dump_response(SkillDetailResponse, result), 201
|
||||||
|
except (ValidationError, ValueError) as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>")
|
||||||
|
class WorkspaceSkillApi(Resource):
|
||||||
|
@console_ns.response(200, "Skill detail", console_ns.models[SkillDetailResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str, skill_id: str):
|
||||||
|
try:
|
||||||
|
result = SkillManagementService().get_skill(tenant_id=current_tenant_id, skill_id=skill_id)
|
||||||
|
return dump_response(SkillDetailResponse, result)
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[SkillMetadataPayload.__name__])
|
||||||
|
@console_ns.response(200, "Skill updated", console_ns.models[SkillResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def patch(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||||
|
try:
|
||||||
|
payload = SkillMetadataPayload.model_validate(console_ns.payload or {})
|
||||||
|
result = SkillManagementService().update_metadata(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return dump_response(SkillResponse, result)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[SkillDeletePayload.__name__])
|
||||||
|
@console_ns.response(200, "Skill deleted", console_ns.models[SkillDeleteResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def delete(self, current_tenant_id: str, skill_id: str):
|
||||||
|
try:
|
||||||
|
payload = SkillDeletePayload.model_validate(console_ns.payload or {})
|
||||||
|
result = SkillManagementService().delete_skill(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
confirmation_name=payload.confirmation_name,
|
||||||
|
)
|
||||||
|
return dump_response(SkillDeleteResponse, result)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/duplicate")
|
||||||
|
class WorkspaceSkillDuplicateApi(Resource):
|
||||||
|
@console_ns.response(201, "Skill duplicated", console_ns.models[SkillDetailResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||||
|
try:
|
||||||
|
result = SkillManagementService().duplicate_skill(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
)
|
||||||
|
return dump_response(SkillDetailResponse, result), 201
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/export")
|
||||||
|
class WorkspaceSkillExportApi(Resource):
|
||||||
|
@console_ns.response(200, "Published Skill zip archive")
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str, skill_id: str):
|
||||||
|
try:
|
||||||
|
result = SkillManagementService().pull_published_archive(tenant_id=current_tenant_id, skill_id=skill_id)
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(result.payload),
|
||||||
|
mimetype=result.mime_type,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=result.filename,
|
||||||
|
)
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/assist/messages")
|
||||||
|
class WorkspaceSkillAssistMessageApi(Resource):
|
||||||
|
"""Stream read-only Skill Authoring suggestions from the default workspace model."""
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[SkillAssistMessagePayload.__name__])
|
||||||
|
@console_ns.response(200, "Skill Authoring assistant event stream")
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||||
|
try:
|
||||||
|
payload = SkillAssistMessagePayload.model_validate(console_ns.payload or {})
|
||||||
|
response = SkillManagementService().create_assistant_action_stream(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
message=payload.message,
|
||||||
|
attachments=payload.attachments,
|
||||||
|
model_payload=payload.model,
|
||||||
|
target_path=payload.target_path,
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
return helper.compact_generate_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files")
|
||||||
|
class WorkspaceSkillFilesApi(Resource):
|
||||||
|
@console_ns.expect(console_ns.models[SkillDraftFileOperationPayload.__name__])
|
||||||
|
@console_ns.response(200, "Draft file operation applied", console_ns.models[SkillDetailResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def patch(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||||
|
try:
|
||||||
|
payload = SkillDraftFileOperationPayload.model_validate(console_ns.payload or {})
|
||||||
|
result = SkillManagementService().apply_draft_file_operation(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return dump_response(SkillDetailResponse, result)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[SkillDraftTreePayload.__name__])
|
||||||
|
@console_ns.response(200, "Draft files replaced", console_ns.models[SkillDetailResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def put(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||||
|
try:
|
||||||
|
payload = SkillDraftTreePayload.model_validate(console_ns.payload or {})
|
||||||
|
result = SkillManagementService().replace_draft_tree(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return dump_response(SkillDetailResponse, result)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/preview")
|
||||||
|
class WorkspaceSkillFilePreviewApi(Resource):
|
||||||
|
@console_ns.doc(params=query_params_from_model(SkillFileQuery))
|
||||||
|
@console_ns.response(200, "Skill file text preview", console_ns.models[SkillFilePreviewResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str, skill_id: str):
|
||||||
|
try:
|
||||||
|
query = SkillFileQuery.model_validate(
|
||||||
|
{
|
||||||
|
"path": request.args.get("path"),
|
||||||
|
"version_id": request.args.get("version_id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = SkillManagementService().preview_file(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
path=query.path,
|
||||||
|
version_id=query.version_id,
|
||||||
|
)
|
||||||
|
return dump_response(SkillFilePreviewResponse, result)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/content")
|
||||||
|
class WorkspaceSkillFileContentApi(Resource):
|
||||||
|
@console_ns.doc(params={**query_params_from_model(SkillFileQuery), "download": "Return as an attachment when 1."})
|
||||||
|
@console_ns.response(200, "Skill file content", console_ns.models[BinaryFileResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str, skill_id: str):
|
||||||
|
try:
|
||||||
|
query = SkillFileQuery.model_validate(
|
||||||
|
{
|
||||||
|
"path": request.args.get("path"),
|
||||||
|
"version_id": request.args.get("version_id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = SkillManagementService().pull_file(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
path=query.path,
|
||||||
|
version_id=query.version_id,
|
||||||
|
)
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(result.payload),
|
||||||
|
mimetype=result.mime_type,
|
||||||
|
as_attachment=request.args.get("download") == "1",
|
||||||
|
download_name=result.filename,
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/publish")
|
||||||
|
class WorkspaceSkillPublishApi(Resource):
|
||||||
|
@console_ns.expect(console_ns.models[SkillPublishPayload.__name__])
|
||||||
|
@console_ns.response(200, "Skill published", console_ns.models[SkillVersionResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||||
|
try:
|
||||||
|
payload = SkillPublishPayload.model_validate(console_ns.payload or {})
|
||||||
|
result = SkillManagementService().publish_skill(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return dump_response(SkillVersionResponse, result)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/restore")
|
||||||
|
class WorkspaceSkillRestoreApi(Resource):
|
||||||
|
@console_ns.expect(console_ns.models[SkillRestorePayload.__name__])
|
||||||
|
@console_ns.response(200, "Skill version restored", console_ns.models[SkillVersionResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||||
|
try:
|
||||||
|
payload = SkillRestorePayload.model_validate(console_ns.payload or {})
|
||||||
|
result = SkillManagementService().restore_version(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return dump_response(SkillVersionResponse, result)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/references")
|
||||||
|
class WorkspaceSkillReferencesApi(Resource):
|
||||||
|
@console_ns.response(200, "Skill references", console_ns.models[SkillReferenceListResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str, skill_id: str):
|
||||||
|
try:
|
||||||
|
result = SkillManagementService().list_skill_references(tenant_id=current_tenant_id, skill_id=skill_id)
|
||||||
|
return dump_response(SkillReferenceListResponse, result)
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions")
|
||||||
|
class WorkspaceSkillVersionsApi(Resource):
|
||||||
|
@console_ns.response(200, "Skill versions", console_ns.models[SkillVersionListResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str, skill_id: str):
|
||||||
|
try:
|
||||||
|
result = SkillManagementService().list_versions(tenant_id=current_tenant_id, skill_id=skill_id)
|
||||||
|
return dump_response(SkillVersionListResponse, result)
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions/<string:version_id>")
|
||||||
|
class WorkspaceSkillVersionApi(Resource):
|
||||||
|
@console_ns.response(200, "Skill version detail", console_ns.models[SkillVersionDetailResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str, skill_id: str, version_id: str):
|
||||||
|
try:
|
||||||
|
result = SkillManagementService().get_version(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
version_id=version_id,
|
||||||
|
)
|
||||||
|
return dump_response(SkillVersionDetailResponse, result)
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[SkillVersionUpdatePayload.__name__])
|
||||||
|
@console_ns.response(200, "Skill version updated", console_ns.models[SkillVersionResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def patch(self, current_tenant_id: str, skill_id: str, version_id: str):
|
||||||
|
try:
|
||||||
|
payload = SkillVersionUpdatePayload.model_validate(console_ns.payload or {})
|
||||||
|
result = SkillManagementService().update_version(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
version_id=version_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return dump_response(SkillVersionResponse, result)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
@console_ns.response(200, "Skill version deleted", console_ns.models[SkillVersionDeleteResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def delete(self, current_tenant_id: str, current_user: Account, skill_id: str, version_id: str):
|
||||||
|
try:
|
||||||
|
result = SkillManagementService().delete_version(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
version_id=version_id,
|
||||||
|
)
|
||||||
|
return dump_response(SkillVersionDeleteResponse, result)
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/workspaces/current/agents/<string:agent_id>/skills")
|
||||||
|
class WorkspaceAgentSkillBindingsApi(Resource):
|
||||||
|
@console_ns.response(200, "Agent Skill bindings", console_ns.models[AgentSkillBindingsResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str, agent_id: str):
|
||||||
|
result = SkillManagementService().list_agent_bindings(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||||
|
return dump_response(AgentSkillBindingsResponse, result)
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[AgentSkillBindingsPayload.__name__])
|
||||||
|
@console_ns.response(200, "Agent Skill bindings replaced", console_ns.models[AgentSkillBindingsResponse.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def put(self, current_tenant_id: str, current_user: Account, agent_id: str):
|
||||||
|
try:
|
||||||
|
payload = AgentSkillBindingsPayload.model_validate(console_ns.payload or {})
|
||||||
|
result = SkillManagementService().replace_agent_bindings(
|
||||||
|
tenant_id=current_tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_ids=payload.skill_ids,
|
||||||
|
)
|
||||||
|
return dump_response(AgentSkillBindingsResponse, result)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WorkspaceAgentSkillBindingsApi",
|
||||||
|
"WorkspaceSkillApi",
|
||||||
|
"WorkspaceSkillDuplicateApi",
|
||||||
|
"WorkspaceSkillExportApi",
|
||||||
|
"WorkspaceSkillFilesApi",
|
||||||
|
"WorkspaceSkillImportApi",
|
||||||
|
"WorkspaceSkillPublishApi",
|
||||||
|
"WorkspaceSkillReferencesApi",
|
||||||
|
"WorkspaceSkillRestoreApi",
|
||||||
|
"WorkspaceSkillTagsApi",
|
||||||
|
"WorkspaceSkillVersionApi",
|
||||||
|
"WorkspaceSkillVersionsApi",
|
||||||
|
"WorkspaceSkillsApi",
|
||||||
|
]
|
||||||
@@ -23,6 +23,7 @@ from .knowledge import retrieval as _knowledge_retrieval
|
|||||||
from .plugin import agent_config as _agent_config
|
from .plugin import agent_config as _agent_config
|
||||||
from .plugin import agent_drive as _agent_drive
|
from .plugin import agent_drive as _agent_drive
|
||||||
from .plugin import plugin as _plugin
|
from .plugin import plugin as _plugin
|
||||||
|
from .plugin import skills as _skills
|
||||||
from .workspace import workspace as _workspace
|
from .workspace import workspace as _workspace
|
||||||
|
|
||||||
api.add_namespace(inner_api_ns)
|
api.add_namespace(inner_api_ns)
|
||||||
@@ -36,6 +37,7 @@ __all__ = [
|
|||||||
"_mail",
|
"_mail",
|
||||||
"_plugin",
|
"_plugin",
|
||||||
"_runtime_credentials",
|
"_runtime_credentials",
|
||||||
|
"_skills",
|
||||||
"_workspace",
|
"_workspace",
|
||||||
"api",
|
"api",
|
||||||
"bp",
|
"bp",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Inner API for published workspace Skills.
|
||||||
|
|
||||||
|
These endpoints are called by trusted runtime services. They expose only
|
||||||
|
published Skill artifacts, never draft files or editable metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
from flask import request, send_file
|
||||||
|
from flask_restx import Resource
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from controllers.console.wraps import setup_required
|
||||||
|
from controllers.inner_api import inner_api_ns
|
||||||
|
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||||
|
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
|
||||||
|
|
||||||
|
|
||||||
|
class _SkillTargetQuery(BaseModel):
|
||||||
|
tenant_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def _target_query_from_request() -> _SkillTargetQuery:
|
||||||
|
return _SkillTargetQuery.model_validate({"tenant_id": request.args.get("tenant_id")})
|
||||||
|
|
||||||
|
|
||||||
|
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, str], int]:
|
||||||
|
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||||
|
|
||||||
|
|
||||||
|
@inner_api_ns.route("/skills/<string:skill_id>/pull")
|
||||||
|
class PublishedSkillPullApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@plugin_inner_api_only
|
||||||
|
@inner_api_ns.doc("published_skill_pull")
|
||||||
|
def get(self, skill_id: str):
|
||||||
|
try:
|
||||||
|
query = _target_query_from_request()
|
||||||
|
result = SkillManagementService().pull_published_archive(tenant_id=query.tenant_id, skill_id=skill_id)
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(result.payload),
|
||||||
|
mimetype=result.mime_type,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=result.filename,
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
return _error_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PublishedSkillPullApi"]
|
||||||
@@ -16,7 +16,6 @@ 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 (
|
||||||
@@ -32,7 +31,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.workspace_retirement_layer import build_workflow_agent_workspace_retirement_layer
|
from core.workflow.nodes.agent_v2.session_cleanup_layer import build_workflow_agent_session_cleanup_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,
|
||||||
@@ -268,18 +267,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
|
|||||||
)
|
)
|
||||||
|
|
||||||
workflow_entry.graph_engine.layer(persistence_layer)
|
workflow_entry.graph_engine.layer(persistence_layer)
|
||||||
workflow_entry.graph_engine.layer(
|
workflow_entry.graph_engine.layer(build_workflow_agent_session_cleanup_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())
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,13 +33,15 @@ 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 AgentAppWorkspaceStore
|
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
|
||||||
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,
|
||||||
@@ -49,24 +51,19 @@ 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, Conversation, EndUser, Message, MessageAnnotation
|
from models import Account, App, AppModelConfig, 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__)
|
||||||
@@ -153,28 +150,26 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
|||||||
inputs = args["inputs"]
|
inputs = args["inputs"]
|
||||||
prompt_file_mappings = args.get("files") or []
|
prompt_file_mappings = args.get("files") or []
|
||||||
|
|
||||||
conversation = None
|
# Resolve the bound roster Agent + its current Agent Soul snapshot.
|
||||||
conversation_id = args.get("conversation_id")
|
|
||||||
if conversation_id:
|
|
||||||
conversation = ConversationService.get_conversation(
|
|
||||||
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(
|
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=args.get("draft_type"),
|
draft_type=args.get("draft_type"),
|
||||||
user=user,
|
user=user,
|
||||||
session=session,
|
session=session,
|
||||||
conversation=conversation,
|
|
||||||
)
|
)
|
||||||
session_scope_config_version_id = self._session_scope_config_version_id(
|
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
|
||||||
invoke_from=invoke_from,
|
invoke_from=invoke_from,
|
||||||
config_version_id=agent_config_id,
|
snapshot_id=agent_config_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
conversation = None
|
||||||
|
conversation_id = args.get("conversation_id")
|
||||||
|
if conversation_id:
|
||||||
|
conversation = ConversationService.get_conversation(
|
||||||
|
app_model=app_model, conversation_id=conversation_id, user=user, session=session
|
||||||
|
)
|
||||||
|
|
||||||
# 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 = (
|
||||||
@@ -191,6 +186,8 @@ 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,
|
||||||
@@ -218,7 +215,8 @@ 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_session_scope_config_version_id=session_scope_config_version_id,
|
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
|
||||||
|
agent_runtime_exit_intent=agent_runtime_exit_intent,
|
||||||
)
|
)
|
||||||
|
|
||||||
conversation, message = self._init_generate_records(
|
conversation, message = self._init_generate_records(
|
||||||
@@ -267,7 +265,6 @@ 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:
|
||||||
@@ -282,21 +279,14 @@ 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=draft_type,
|
draft_type=self._resume_draft_type(
|
||||||
draft_id=draft_id,
|
app_model=app_model, conversation=conversation, user=user, session=session
|
||||||
|
),
|
||||||
user=user,
|
user=user,
|
||||||
session=session,
|
session=session,
|
||||||
conversation=conversation,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app_model_config = (
|
app_model_config = (
|
||||||
@@ -394,37 +384,30 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_resume_draft(
|
def _resume_draft_type(
|
||||||
*,
|
*, app_model: App, conversation: Any, user: Account | EndUser, session: Session
|
||||||
app_model: App,
|
) -> str | None:
|
||||||
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, None
|
return None
|
||||||
if not isinstance(user, Account):
|
active_session = AgentAppRuntimeSessionStore().load_active_session_for_conversation(
|
||||||
return AgentConfigDraftType.DRAFT.value, None
|
tenant_id=app_model.tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
build_draft = session.scalar(
|
conversation_id=conversation.id,
|
||||||
select(AgentConfigDraft)
|
|
||||||
.join(
|
|
||||||
AgentWorkspaceBinding,
|
|
||||||
AgentWorkspaceBinding.id == AgentConfigDraft.agent_workspace_binding_id,
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
AgentConfigDraft.tenant_id == app_model.tenant_id,
|
|
||||||
AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD,
|
|
||||||
AgentConfigDraft.account_id == user.id,
|
|
||||||
AgentWorkspaceBinding.tenant_id == app_model.tenant_id,
|
|
||||||
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
|
|
||||||
AgentWorkspaceBinding.pending_form_id == form_id,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if build_draft is not None:
|
snapshot_id = active_session.scope.agent_config_snapshot_id if active_session is not None else None
|
||||||
return AgentConfigDraftType.DEBUG_BUILD.value, build_draft.id
|
if snapshot_id and isinstance(user, Account):
|
||||||
return AgentConfigDraftType.DRAFT.value, None
|
draft = session.scalar(
|
||||||
|
select(AgentConfigDraft).where(
|
||||||
|
AgentConfigDraft.tenant_id == app_model.tenant_id,
|
||||||
|
AgentConfigDraft.id == snapshot_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if draft is not None:
|
||||||
|
if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD and draft.account_id == user.id:
|
||||||
|
return AgentConfigDraftType.DEBUG_BUILD.value
|
||||||
|
if draft.draft_type == AgentConfigDraftType.DRAFT and draft.account_id is None:
|
||||||
|
return AgentConfigDraftType.DRAFT.value
|
||||||
|
return AgentConfigDraftType.DRAFT.value
|
||||||
|
|
||||||
def _generate_worker(
|
def _generate_worker(
|
||||||
self,
|
self,
|
||||||
@@ -499,7 +482,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, config_version, agent_soul = self._resolve_agent_by_id(
|
_, _, 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,
|
||||||
@@ -513,18 +496,13 @@ 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_session_scope_config_version_id,
|
session_scope_snapshot_id=application_generate_entity.agent_runtime_session_snapshot_id,
|
||||||
build_draft_id=(
|
agent_runtime_exit_intent=application_generate_entity.agent_runtime_exit_intent,
|
||||||
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
|
||||||
@@ -541,6 +519,18 @@ 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)
|
||||||
@@ -556,7 +546,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=AgentAppWorkspaceStore(),
|
session_store=AgentAppRuntimeSessionStore(),
|
||||||
text_delta_debounce_seconds=dify_config.AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS,
|
text_delta_debounce_seconds=dify_config.AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -620,10 +610,8 @@ 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)
|
||||||
@@ -655,7 +643,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -668,82 +655,28 @@ 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=snapshot_id,
|
snapshot_id=agent.active_config_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 _resolve_conversation_binding(
|
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
|
||||||
*,
|
"""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 Workspace Binding continuity
|
immutable published snapshot ids. This keeps runtime session continuity
|
||||||
inside one editable surface without mixing draft/build/published state.
|
inside one editable surface without mixing draft/build/published state.
|
||||||
"""
|
"""
|
||||||
del invoke_from
|
return snapshot_id
|
||||||
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
|
||||||
@@ -767,8 +700,6 @@ 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
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
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 saves the latest Agenton snapshot on the persistent Binding.
|
pipeline, and then either saves or retires the conversation-owned runtime
|
||||||
|
session depending on the turn's exit policy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -30,20 +31,22 @@ 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 DifyRunContext
|
from core.app.entities.app_invoke_entities import AgentRuntimeExitIntent, DifyRunContext
|
||||||
from core.app.entities.queue_entities import (
|
from core.app.entities.queue_entities import (
|
||||||
QueueAgentMessageEvent,
|
QueueAgentMessageEvent,
|
||||||
QueueAgentThoughtEvent,
|
QueueAgentThoughtEvent,
|
||||||
@@ -64,10 +67,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__)
|
||||||
|
|
||||||
@@ -617,7 +620,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: AgentAppWorkspaceStore,
|
session_store: AgentAppRuntimeSessionStore,
|
||||||
text_delta_debounce_seconds: float,
|
text_delta_debounce_seconds: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._request_builder = request_builder
|
self._request_builder = request_builder
|
||||||
@@ -634,41 +637,37 @@ 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,
|
||||||
build_draft_id: str | None = None,
|
agent_runtime_exit_intent: AgentRuntimeExitIntent = "suspend",
|
||||||
) -> 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_or_create(scope)
|
stored = self._session_store.load_active_session(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)
|
||||||
@@ -682,6 +681,9 @@ 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(
|
||||||
@@ -701,8 +703,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 == "binding_lost":
|
if reason == "sandbox_expired":
|
||||||
raise AgentBackendError("The retained agent working environment is no longer available.")
|
raise AgentBackendError("The agent session sandbox has expired. Please start a new conversation.")
|
||||||
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.")
|
||||||
|
|
||||||
@@ -717,18 +719,38 @@ class AgentAppRunner:
|
|||||||
message_id,
|
message_id,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
self._publish_terminal_answer(
|
if preserve_session:
|
||||||
queue_manager=queue_manager,
|
superseded_sessions = self._load_superseded_sessions(scope=scope)
|
||||||
model_name=model_name,
|
self._publish_terminal_answer(
|
||||||
answer=answer,
|
queue_manager=queue_manager,
|
||||||
query=query,
|
model_name=model_name,
|
||||||
usage=_llm_usage_from_agent_backend(terminal.usage),
|
answer=answer,
|
||||||
)
|
query=query,
|
||||||
self._save_session(
|
usage=_llm_usage_from_agent_backend(terminal.usage),
|
||||||
scope=scope,
|
)
|
||||||
binding_id=runtime.binding_id,
|
session_saved = self._save_session(
|
||||||
snapshot=terminal.session_snapshot,
|
scope=scope,
|
||||||
)
|
backend_run_id=terminal.run_id,
|
||||||
|
snapshot=terminal.session_snapshot,
|
||||||
|
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
|
||||||
|
)
|
||||||
|
if session_saved:
|
||||||
|
self._cleanup_superseded_sessions(superseded_sessions)
|
||||||
|
else:
|
||||||
|
# The backend has already accepted a terminal success with
|
||||||
|
# delete-on-exit semantics. Local publish/persistence errors must
|
||||||
|
# not keep the API-side session row active, and cleanup failures
|
||||||
|
# must not replace the original publish/error outcome.
|
||||||
|
try:
|
||||||
|
self._publish_terminal_answer(
|
||||||
|
queue_manager=queue_manager,
|
||||||
|
model_name=model_name,
|
||||||
|
answer=answer,
|
||||||
|
query=query,
|
||||||
|
usage=_llm_usage_from_agent_backend(terminal.usage),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._mark_session_cleaned(scope=scope, backend_run_id=terminal.run_id)
|
||||||
|
|
||||||
def _build_session_scope(
|
def _build_session_scope(
|
||||||
self,
|
self,
|
||||||
@@ -736,11 +758,8 @@ 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
|
||||||
@@ -751,10 +770,7 @@ 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 or agent_config_snapshot_id,
|
agent_config_snapshot_id=effective_session_scope_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(
|
||||||
@@ -765,15 +781,14 @@ 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,
|
stored: StoredAgentAppSession | None,
|
||||||
message_id: str | None,
|
message_id: str | None,
|
||||||
|
suspend_on_exit: bool,
|
||||||
) -> AgentAppRuntimeRequest:
|
) -> AgentAppRuntimeRequest:
|
||||||
session_snapshot = stored.session_snapshot
|
session_snapshot = stored.session_snapshot if stored is not None else None
|
||||||
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
|
||||||
@@ -789,10 +804,9 @@ 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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -829,8 +843,9 @@ 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,
|
||||||
binding_id=runtime.binding_id,
|
backend_run_id=terminal.run_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,
|
||||||
)
|
)
|
||||||
@@ -847,12 +862,12 @@ class AgentAppRunner:
|
|||||||
def _resolve_pending_ask_human(
|
def _resolve_pending_ask_human(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
stored: StoredAgentAppSession,
|
stored: StoredAgentAppSession | None,
|
||||||
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.pending_form_id is None or stored.pending_tool_call_id is None:
|
if stored is None or 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,
|
||||||
@@ -1021,16 +1036,18 @@ class AgentAppRunner:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
scope: AgentAppSessionScope,
|
scope: AgentAppSessionScope,
|
||||||
binding_id: str,
|
backend_run_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,
|
||||||
binding_id=binding_id,
|
backend_run_id=backend_run_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,
|
||||||
)
|
)
|
||||||
@@ -1047,6 +1064,87 @@ 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.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
|||||||
build_config_layer_config,
|
build_config_layer_config,
|
||||||
build_knowledge_layer_config,
|
build_knowledge_layer_config,
|
||||||
build_shell_layer_config,
|
build_shell_layer_config,
|
||||||
|
load_runtime_agent_skill_configs,
|
||||||
)
|
)
|
||||||
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
|
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
|
||||||
from models.provider_ids import ModelProviderID
|
from models.provider_ids import ModelProviderID
|
||||||
@@ -70,12 +71,11 @@ 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)
|
||||||
@@ -83,7 +83,6 @@ 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:
|
||||||
@@ -127,14 +126,22 @@ class AgentAppRuntimeRequestBuilder:
|
|||||||
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime_config_skills = load_runtime_agent_skill_configs(
|
||||||
|
tenant_id=context.dify_context.tenant_id,
|
||||||
|
agent_id=context.agent_id,
|
||||||
|
)
|
||||||
config_layer_config, config_warnings = build_config_layer_config(
|
config_layer_config, config_warnings = build_config_layer_config(
|
||||||
agent_soul,
|
agent_soul,
|
||||||
agent_id=context.agent_id,
|
agent_id=context.agent_id,
|
||||||
config_version_id=context.agent_config_snapshot_id,
|
config_version_id=context.agent_config_snapshot_id,
|
||||||
config_version_kind=context.agent_config_version_kind,
|
config_version_kind=context.agent_config_version_kind,
|
||||||
|
runtime_config_skills=runtime_config_skills,
|
||||||
)
|
)
|
||||||
append_runtime_warnings(metadata, config_warnings)
|
append_runtime_warnings(metadata, config_warnings)
|
||||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
|
||||||
|
agent_soul,
|
||||||
|
runtime_config_skills=runtime_config_skills,
|
||||||
|
)
|
||||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||||
|
|
||||||
request = self._request_builder.build_for_agent_app(
|
request = self._request_builder.build_for_agent_app(
|
||||||
@@ -162,7 +169,6 @@ 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()
|
||||||
@@ -178,17 +184,13 @@ 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(
|
return AgentAppRuntimeRequest(request=request, redacted_request=redacted, metadata=metadata)
|
||||||
request=request,
|
|
||||||
redacted_request=redacted,
|
|
||||||
metadata=metadata,
|
|
||||||
binding_id=context.binding_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_tool_layers(
|
def _build_tool_layers(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,176 +1,255 @@
|
|||||||
"""Persist and resolve the exact participant owned by an Agent App caller."""
|
"""Conversation-keyed Agent backend session store for the Agent App type.
|
||||||
|
|
||||||
|
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
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
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 (
|
||||||
AgentConfigDraft,
|
AgentRuntimeSession,
|
||||||
AgentConfigDraftType,
|
AgentRuntimeSessionOwnerType,
|
||||||
AgentConfigVersionKind,
|
AgentRuntimeSessionStatus,
|
||||||
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
|
agent_config_snapshot_id: str | None
|
||||||
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
|
||||||
binding_id: str
|
session_snapshot: CompositorSessionSnapshot
|
||||||
workspace_id: str
|
backend_run_id: str | None
|
||||||
backend_binding_ref: str
|
runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list)
|
||||||
session_snapshot: CompositorSessionSnapshot | None
|
# ENG-635: set while the conversation turn is paused on a dify.ask_human
|
||||||
|
# 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 AgentAppWorkspaceStore:
|
class AgentAppRuntimeSessionStore:
|
||||||
"""Resolve Agent App sessions through a caller-owned Binding pointer."""
|
"""Persists Agent backend session snapshots for Agent App conversations."""
|
||||||
|
|
||||||
def load_or_create(self, scope: AgentAppSessionScope) -> StoredAgentAppSession:
|
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
|
||||||
|
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:
|
||||||
caller = self._load_caller(session=session, scope=scope)
|
row = session.scalar(self._active_stmt(scope))
|
||||||
binding_id = caller.agent_workspace_binding_id
|
if row is None:
|
||||||
if binding_id is None:
|
return None
|
||||||
binding = AgentWorkspaceService.create_binding(
|
return StoredAgentAppSession(
|
||||||
session=session,
|
scope=scope,
|
||||||
scope=scope.workspace_owner,
|
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||||
agent_id=scope.agent_id,
|
backend_run_id=row.backend_run_id,
|
||||||
base_home_snapshot_id=scope.home_snapshot_id,
|
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
|
||||||
agent_config_version_id=scope.agent_config_snapshot_id,
|
pending_form_id=row.pending_form_id,
|
||||||
agent_config_version_kind=scope.agent_config_version_kind,
|
pending_tool_call_id=row.pending_tool_call_id,
|
||||||
)
|
|
||||||
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")
|
def load_active_session_for_conversation(
|
||||||
return draft
|
self, *, tenant_id: str, app_id: str, conversation_id: str
|
||||||
if scope.agent_config_version_kind == AgentConfigVersionKind.BUILD_DRAFT:
|
) -> StoredAgentAppSession | None:
|
||||||
raise AgentWorkspaceNotFoundError("Build Draft caller ID is required")
|
"""Load the latest ACTIVE session for one conversation-level sandbox lookup.
|
||||||
conversation = session.scalar(
|
|
||||||
select(Conversation)
|
Sandbox inspection only knows the product locator
|
||||||
.join(App, App.id == Conversation.app_id)
|
``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(
|
.where(
|
||||||
App.tenant_id == scope.tenant_id,
|
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||||
Conversation.id == scope.conversation_id,
|
AgentRuntimeSession.tenant_id == tenant_id,
|
||||||
Conversation.app_id == scope.app_id,
|
AgentRuntimeSession.app_id == app_id,
|
||||||
Conversation.is_deleted.is_(False),
|
AgentRuntimeSession.conversation_id == conversation_id,
|
||||||
|
AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE,
|
||||||
)
|
)
|
||||||
|
.order_by(AgentRuntimeSession.updated_at.desc())
|
||||||
)
|
)
|
||||||
if conversation is None:
|
with session_factory.create_session() as session:
|
||||||
raise AgentWorkspaceNotFoundError("Conversation caller is unavailable")
|
row = session.scalar(stmt)
|
||||||
return conversation
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
def list_active_sessions_for_conversation(
|
||||||
def _get_binding(
|
self, *, tenant_id: str, app_id: str, conversation_id: str
|
||||||
*,
|
) -> list[StoredAgentAppSession]:
|
||||||
session: Session,
|
"""List all ACTIVE conversation-owned sessions for lifecycle cleanup."""
|
||||||
scope: AgentAppSessionScope,
|
stmt = (
|
||||||
binding_id: str,
|
select(AgentRuntimeSession)
|
||||||
) -> AgentWorkspaceBinding:
|
.where(
|
||||||
binding = AgentWorkspaceService.get_active_binding(
|
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||||
session=session,
|
AgentRuntimeSession.tenant_id == tenant_id,
|
||||||
tenant_id=scope.tenant_id,
|
AgentRuntimeSession.app_id == app_id,
|
||||||
binding_id=binding_id,
|
AgentRuntimeSession.conversation_id == conversation_id,
|
||||||
expected_owner_scope=scope.workspace_owner,
|
AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
.order_by(AgentRuntimeSession.updated_at.desc())
|
||||||
)
|
)
|
||||||
if binding is None or binding.agent_id != scope.agent_id:
|
with session_factory.create_session() as session:
|
||||||
raise AgentWorkspaceNotFoundError("Caller participant Binding is unavailable")
|
rows = session.scalars(stmt).all()
|
||||||
AgentWorkspaceService.validate_binding_generation(
|
return [
|
||||||
binding,
|
StoredAgentAppSession(
|
||||||
base_home_snapshot_id=scope.home_snapshot_id,
|
scope=AgentAppSessionScope(
|
||||||
agent_config_version_id=scope.agent_config_snapshot_id,
|
tenant_id=row.tenant_id,
|
||||||
agent_config_version_kind=scope.agent_config_version_kind,
|
app_id=row.app_id,
|
||||||
)
|
conversation_id=row.conversation_id or "",
|
||||||
return binding
|
agent_id=row.agent_id,
|
||||||
|
agent_config_snapshot_id=row.agent_config_snapshot_id,
|
||||||
|
),
|
||||||
|
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||||
|
backend_run_id=row.backend_run_id,
|
||||||
|
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
|
||||||
|
pending_form_id=row.pending_form_id,
|
||||||
|
pending_tool_call_id=row.pending_tool_call_id,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
def save_active_snapshot(
|
def save_active_snapshot(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
scope: AgentAppSessionScope,
|
scope: AgentAppSessionScope,
|
||||||
binding_id: str,
|
backend_run_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
|
||||||
AgentWorkspaceService.save_binding_session_snapshot(
|
snapshot_json = snapshot.model_dump_json()
|
||||||
tenant_id=scope.tenant_id,
|
runtime_layer_specs_json = _serialize_runtime_layer_specs(runtime_layer_specs)
|
||||||
binding_id=binding_id,
|
with session_factory.create_session() as session:
|
||||||
session_snapshot=snapshot.model_dump_json(),
|
row = session.scalar(self._scope_stmt(scope))
|
||||||
pending_form_id=pending_form_id,
|
if row is None:
|
||||||
pending_tool_call_id=pending_tool_call_id,
|
row = AgentRuntimeSession(
|
||||||
)
|
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 _stored(scope: AgentAppSessionScope, binding: AgentWorkspaceBinding) -> StoredAgentAppSession:
|
def _scope_stmt(scope: AgentAppSessionScope):
|
||||||
snapshot = (
|
stmt = select(AgentRuntimeSession).where(
|
||||||
CompositorSessionSnapshot.model_validate_json(binding.session_snapshot)
|
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||||
if binding.session_snapshot
|
AgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||||
else None
|
AgentRuntimeSession.conversation_id == scope.conversation_id,
|
||||||
)
|
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__ = ["AgentAppSessionScope", "AgentAppWorkspaceStore", "StoredAgentAppSession"]
|
__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope", "StoredAgentAppSession"]
|
||||||
|
|||||||
@@ -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 DifyRunContext, InvokeFrom, WorkflowAppGenerateEntity
|
from core.app.entities.app_invoke_entities import 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.workspace_retirement_layer import build_workflow_agent_workspace_retirement_layer
|
from core.workflow.nodes.agent_v2.session_cleanup_layer import build_workflow_agent_session_cleanup_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,18 +197,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
|
|||||||
)
|
)
|
||||||
|
|
||||||
workflow_entry.graph_engine.layer(persistence_layer)
|
workflow_entry.graph_engine.layer(persistence_layer)
|
||||||
workflow_entry.graph_engine.layer(
|
workflow_entry.graph_engine.layer(build_workflow_agent_session_cleanup_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)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ 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):
|
||||||
@@ -225,8 +227,12 @@ 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_session_scope_config_version_id`` identifies the draft or immutable
|
``agent_runtime_session_snapshot_id`` carries the runtime session scope
|
||||||
config version whose Workspace Binding should be reused for this session.
|
used to resume or suspend within the same editable config surface.
|
||||||
|
|
||||||
|
``agent_runtime_exit_intent`` is API-internal lifecycle policy for the
|
||||||
|
Agent backend session after this turn finishes. Normal chat/resume turns
|
||||||
|
suspend on exit; build-chat finalization deletes the backend runtime.
|
||||||
|
|
||||||
``prompt_file_mappings`` preserves the raw request ``files`` array for the
|
``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
|
||||||
@@ -236,7 +242,8 @@ 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_session_scope_config_version_id: str | None = None
|
agent_runtime_session_snapshot_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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ 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,
|
||||||
@@ -243,10 +241,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._node_execution_cache[event.id] = domain_execution
|
self._node_execution_cache[event.id] = domain_execution
|
||||||
if event.node_type == BuiltinNodeTypes.AGENT and event.node_version == "2":
|
self._workflow_node_execution_repository.save(domain_execution)
|
||||||
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,
|
||||||
@@ -366,11 +361,7 @@ 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 = preserve_workflow_agent_binding_id(
|
process_data = dict(execution.process_data or {})
|
||||||
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(
|
||||||
@@ -399,12 +390,11 @@ 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 merged_process_data
|
return next_process_data
|
||||||
|
|
||||||
merged_process_data = dict(merged_process_data or {})
|
merged_process_data = dict(next_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
|
||||||
|
|
||||||
@@ -450,11 +440,6 @@ 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,9 +16,6 @@ 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
|
||||||
@@ -52,7 +49,6 @@ 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,
|
||||||
@@ -102,13 +98,6 @@ 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",
|
||||||
@@ -160,17 +149,6 @@ 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,
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ 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,7 +18,6 @@ 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
|
||||||
@@ -373,12 +372,6 @@ 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.
|
||||||
@@ -393,13 +386,6 @@ 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("_"):
|
||||||
@@ -456,25 +442,18 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
|
|||||||
else:
|
else:
|
||||||
db_model.outputs = self._json_encode(domain_model.outputs)
|
db_model.outputs = self._json_encode(domain_model.outputs)
|
||||||
|
|
||||||
process_data = preserve_workflow_agent_binding_id(db_model.process_data_dict, domain_model.process_data)
|
if domain_model.process_data is not None:
|
||||||
if process_data is not None:
|
|
||||||
result = self._truncate_and_upload(
|
result = self._truncate_and_upload(
|
||||||
process_data,
|
domain_model.process_data,
|
||||||
domain_model.id,
|
domain_model.id,
|
||||||
ExecutionOffLoadType.PROCESS_DATA,
|
ExecutionOffLoadType.PROCESS_DATA,
|
||||||
)
|
)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
truncated_process_data = preserve_workflow_agent_binding_id(
|
db_model.process_data = self._json_encode(result.truncated_value)
|
||||||
process_data,
|
domain_model.set_truncated_process_data(result.truncated_value)
|
||||||
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(process_data)
|
db_model.process_data = self._json_encode(domain_model.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():
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -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 WorkflowAgentWorkspaceStore
|
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentRuntimeSessionStore
|
||||||
|
|
||||||
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": WorkflowAgentWorkspaceStore(),
|
"session_store": WorkflowAgentRuntimeSessionStore(),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"strategy_resolver": self._agent_strategy_resolver,
|
"strategy_resolver": self._agent_strategy_resolver,
|
||||||
|
|||||||
@@ -198,23 +198,8 @@ 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
|
||||||
@@ -290,24 +275,6 @@ 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,
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ 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
|
||||||
@@ -30,12 +33,11 @@ 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.graph_events import NodeRunPauseRequestedEvent
|
from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent
|
||||||
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 services.agent.workspace_service import AgentWorkspaceNotFoundError
|
from tasks.agent_backend_session_cleanup_task import cleanup_workflow_agent_runtime_session
|
||||||
|
|
||||||
from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason
|
from .ask_human_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
|
||||||
@@ -54,7 +56,7 @@ from .runtime_request_builder import (
|
|||||||
WorkflowAgentRuntimeRequestBuilder,
|
WorkflowAgentRuntimeRequestBuilder,
|
||||||
WorkflowAgentRuntimeRequestBuildError,
|
WorkflowAgentRuntimeRequestBuildError,
|
||||||
)
|
)
|
||||||
from .session_store import WorkflowAgentSessionScope, WorkflowAgentWorkspaceStore
|
from .session_store import WorkflowAgentRuntimeSessionStore, WorkflowAgentSessionScope
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from graphon.entities import GraphInitParams
|
from graphon.entities import GraphInitParams
|
||||||
@@ -66,7 +68,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.
|
||||||
type _TerminalAgentBackendEvent = (
|
_TerminalAgentBackendEvent = (
|
||||||
AgentBackendRunSucceededInternalEvent
|
AgentBackendRunSucceededInternalEvent
|
||||||
| AgentBackendRunFailedInternalEvent
|
| AgentBackendRunFailedInternalEvent
|
||||||
| AgentBackendRunCancelledInternalEvent
|
| AgentBackendRunCancelledInternalEvent
|
||||||
@@ -91,7 +93,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: WorkflowAgentWorkspaceStore,
|
session_store: WorkflowAgentRuntimeSessionStore | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
@@ -128,34 +130,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
|||||||
return reason
|
return reason
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def _run(self) -> Generator[NodeEventBase | NodeRunPauseRequestedEvent, None, None]:
|
def _run(self) -> Generator[NodeEventBase, 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(
|
||||||
@@ -168,24 +143,21 @@ 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(
|
||||||
@@ -196,31 +168,20 @@ 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.update(
|
process_data = {
|
||||||
{
|
"agent_id": bundle.agent.id,
|
||||||
"agent_id": bundle.agent.id,
|
"agent_config_snapshot_id": bundle.snapshot.id,
|
||||||
"agent_config_snapshot_id": bundle.snapshot.id,
|
"binding_id": bundle.binding.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.execution_id,
|
node_execution_id=self.id,
|
||||||
workflow_agent_binding_id=bundle.binding.id,
|
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,
|
||||||
)
|
)
|
||||||
@@ -239,53 +200,47 @@ 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
|
||||||
stored_session = self._session_store.load_or_create_node_execution_session(
|
if self._session_store is not None:
|
||||||
session_scope,
|
stored_session = self._session_store.load_active_session(session_scope)
|
||||||
home_snapshot_id=bundle.snapshot.home_snapshot_id,
|
if stored_session is not None and stored_session.pending_form_id is not None:
|
||||||
)
|
resume_outcome = resolve_ask_human_form(
|
||||||
if stored_session.pending_form_id is not None:
|
form_id=stored_session.pending_form_id,
|
||||||
resume_outcome = resolve_ask_human_form(
|
tenant_id=dify_ctx.tenant_id,
|
||||||
form_id=stored_session.pending_form_id,
|
node_id=self._node_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.execution_id,
|
node_execution_id=self.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=stored_session.session_snapshot,
|
session_snapshot=session_snapshot,
|
||||||
deferred_tool_results=deferred_tool_results,
|
deferred_tool_results=deferred_tool_results,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -311,9 +266,8 @@ 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.clear()
|
metadata = dict(runtime_request.metadata)
|
||||||
metadata.update(runtime_request.metadata)
|
|
||||||
metadata["attempt"] = attempt
|
metadata["attempt"] = attempt
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -334,12 +288,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
|||||||
"status": create_response.status,
|
"status": create_response.status,
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal_event, exhausted = self._consume_event_stream(
|
terminal_event, exhausted = self._consume_event_stream(create_response.run_id, metadata)
|
||||||
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.
|
||||||
@@ -400,23 +349,29 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
|||||||
)
|
)
|
||||||
self._save_session_snapshot(
|
self._save_session_snapshot(
|
||||||
session_scope=session_scope,
|
session_scope=session_scope,
|
||||||
binding_id=stored_session.binding_id,
|
backend_run_id=terminal_event.run_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 self._pause_event(
|
yield PauseRequestedEvent(reason=self._to_graph_pause_reason(pause_reason))
|
||||||
reason=pause_reason,
|
|
||||||
inputs=inputs,
|
|
||||||
process_data=process_data,
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# A failed attempt does not retire the product-owned Binding. The
|
# Non-success terminal (failed / cancelled) skips per-output
|
||||||
# Workflow Run terminal lifecycle event owns that transition.
|
# 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):
|
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,
|
||||||
|
)
|
||||||
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,
|
||||||
@@ -429,8 +384,9 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
|||||||
|
|
||||||
self._save_session_snapshot(
|
self._save_session_snapshot(
|
||||||
session_scope=session_scope,
|
session_scope=session_scope,
|
||||||
binding_id=stored_session.binding_id,
|
backend_run_id=terminal_event.run_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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -502,9 +458,6 @@ 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,
|
||||||
@@ -554,8 +507,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",
|
||||||
@@ -563,8 +516,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),
|
||||||
@@ -572,8 +525,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",
|
||||||
@@ -643,17 +596,21 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
session_scope: WorkflowAgentSessionScope,
|
session_scope: WorkflowAgentSessionScope,
|
||||||
binding_id: str,
|
backend_run_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,
|
||||||
binding_id=binding_id,
|
backend_run_id=backend_run_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,
|
||||||
)
|
)
|
||||||
@@ -662,18 +619,88 @@ 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 Binding session snapshot: "
|
"Failed to persist workflow Agent runtime session snapshot: "
|
||||||
"tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s",
|
"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.tenant_id,
|
||||||
session_scope.workflow_run_id,
|
session_scope.workflow_run_id,
|
||||||
session_scope.node_id,
|
session_scope.node_id,
|
||||||
session_scope.workflow_agent_binding_id,
|
session_scope.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_workspace_store_error"
|
agent_backend["session_snapshot_persist_error"] = "workflow_agent_runtime_session_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
|
||||||
@@ -715,27 +742,6 @@ 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,27 +41,18 @@ 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:
|
||||||
"""Resolve the current binding, optionally at a generation pinned by an existing execution."""
|
|
||||||
|
|
||||||
if (binding_id is None) != (snapshot_id is None):
|
|
||||||
raise WorkflowAgentBindingError(
|
|
||||||
"agent_binding_generation_invalid",
|
|
||||||
"Workflow Agent binding and config snapshot must be pinned together.",
|
|
||||||
)
|
|
||||||
|
|
||||||
with session_factory.create_session() as session:
|
with session_factory.create_session() as session:
|
||||||
binding_stmt = select(WorkflowAgentNodeBinding).where(
|
binding = session.scalar(
|
||||||
WorkflowAgentNodeBinding.tenant_id == tenant_id,
|
select(WorkflowAgentNodeBinding)
|
||||||
WorkflowAgentNodeBinding.app_id == app_id,
|
.where(
|
||||||
WorkflowAgentNodeBinding.workflow_id == workflow_id,
|
WorkflowAgentNodeBinding.tenant_id == tenant_id,
|
||||||
WorkflowAgentNodeBinding.node_id == node_id,
|
WorkflowAgentNodeBinding.app_id == app_id,
|
||||||
|
WorkflowAgentNodeBinding.workflow_id == workflow_id,
|
||||||
|
WorkflowAgentNodeBinding.node_id == node_id,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
)
|
)
|
||||||
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",
|
||||||
@@ -86,16 +77,12 @@ 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.",
|
||||||
)
|
)
|
||||||
|
|
||||||
effective_snapshot_id = (
|
snapshot_id = (
|
||||||
(
|
agent.active_config_snapshot_id
|
||||||
agent.active_config_snapshot_id
|
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
|
||||||
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
|
else binding.current_snapshot_id
|
||||||
else binding.current_snapshot_id
|
|
||||||
)
|
|
||||||
if snapshot_id is None
|
|
||||||
else snapshot_id
|
|
||||||
)
|
)
|
||||||
if effective_snapshot_id is None:
|
if 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.",
|
||||||
@@ -106,14 +93,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 == effective_snapshot_id,
|
AgentConfigSnapshot.id == 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 {effective_snapshot_id} not found.",
|
f"Agent config snapshot {snapshot_id} not found.",
|
||||||
)
|
)
|
||||||
|
|
||||||
session.expunge(binding)
|
session.expunge(binding)
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ 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
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
from clients.agent_backend import (
|
from clients.agent_backend import (
|
||||||
AgentBackendModelConfig,
|
AgentBackendModelConfig,
|
||||||
@@ -135,8 +137,6 @@ 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
|
||||||
@@ -207,14 +207,22 @@ class WorkflowAgentRuntimeRequestBuilder:
|
|||||||
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime_config_skills = load_runtime_agent_skill_configs(
|
||||||
|
tenant_id=context.dify_context.tenant_id,
|
||||||
|
agent_id=context.agent.id,
|
||||||
|
)
|
||||||
config_layer_config, config_warnings = build_config_layer_config(
|
config_layer_config, config_warnings = build_config_layer_config(
|
||||||
agent_soul,
|
agent_soul,
|
||||||
agent_id=context.agent.id,
|
agent_id=context.agent.id,
|
||||||
config_version_id=context.snapshot.id,
|
config_version_id=context.snapshot.id,
|
||||||
config_version_kind="snapshot",
|
config_version_kind="snapshot",
|
||||||
|
runtime_config_skills=runtime_config_skills,
|
||||||
)
|
)
|
||||||
append_runtime_warnings(metadata, config_warnings)
|
append_runtime_warnings(metadata, config_warnings)
|
||||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
|
||||||
|
agent_soul,
|
||||||
|
runtime_config_skills=runtime_config_skills,
|
||||||
|
)
|
||||||
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||||
|
|
||||||
@@ -252,7 +260,6 @@ 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,
|
||||||
@@ -741,6 +748,7 @@ 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
|
||||||
@@ -751,6 +759,12 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -878,11 +892,16 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s
|
|||||||
existing.extend(warnings)
|
existing.extend(warnings)
|
||||||
|
|
||||||
|
|
||||||
def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
|
def build_config_aware_soul_mention_resolver(
|
||||||
|
agent_soul: AgentSoulConfig,
|
||||||
|
*,
|
||||||
|
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
|
||||||
|
):
|
||||||
"""Resolve config skill/file mentions and delegate the rest to Agent Soul."""
|
"""Resolve config skill/file mentions and delegate the rest to Agent Soul."""
|
||||||
|
|
||||||
base_resolver = build_soul_mention_resolver(agent_soul)
|
base_resolver = build_soul_mention_resolver(agent_soul)
|
||||||
skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
|
skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
|
||||||
|
skill_names.update(item.name for item in runtime_config_skills)
|
||||||
file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
|
file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
|
||||||
|
|
||||||
def _resolve(mention: object) -> str | None:
|
def _resolve(mention: object) -> str | None:
|
||||||
@@ -900,12 +919,34 @@ def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
|
|||||||
return _resolve
|
return _resolve
|
||||||
|
|
||||||
|
|
||||||
|
def load_runtime_agent_skill_configs(*, tenant_id: str, agent_id: str) -> list[DifyConfigSkillConfig]:
|
||||||
|
"""Return workspace-bound Skills as prompt-safe runtime config skills."""
|
||||||
|
from services.skill_management_service import SkillManagementService
|
||||||
|
|
||||||
|
try:
|
||||||
|
runtime_skills = SkillManagementService().list_runtime_agent_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||||
|
except OperationalError as exc:
|
||||||
|
if "no such table: agent_skill_bindings" not in str(exc.orig):
|
||||||
|
raise
|
||||||
|
runtime_skills = []
|
||||||
|
return [
|
||||||
|
DifyConfigSkillConfig(
|
||||||
|
name=str(item["name"]),
|
||||||
|
description=str(item.get("description") or ""),
|
||||||
|
size=cast(int | None, item.get("size")),
|
||||||
|
mime_type=cast(str | None, item.get("mime_type")),
|
||||||
|
)
|
||||||
|
for item in runtime_skills
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def build_config_layer_config(
|
def build_config_layer_config(
|
||||||
agent_soul: AgentSoulConfig,
|
agent_soul: AgentSoulConfig,
|
||||||
*,
|
*,
|
||||||
agent_id: str | None = None,
|
agent_id: str | None = None,
|
||||||
config_version_id: str | None = None,
|
config_version_id: str | None = None,
|
||||||
config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
|
config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
|
||||||
|
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
|
||||||
) -> tuple[DifyConfigLayerConfig, list[dict[str, str]]]:
|
) -> tuple[DifyConfigLayerConfig, list[dict[str, str]]]:
|
||||||
"""Build the always-present Agent config layer from Agent Soul state.
|
"""Build the always-present Agent config layer from Agent Soul state.
|
||||||
|
|
||||||
@@ -922,8 +963,23 @@ def build_config_layer_config(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
available_skills = [skill for skill in agent_soul.config_skills if not skill.is_missing]
|
available_skills = [skill for skill in agent_soul.config_skills if not skill.is_missing]
|
||||||
|
skill_configs = [
|
||||||
|
DifyConfigSkillConfig(
|
||||||
|
name=skill.name,
|
||||||
|
description=skill.description,
|
||||||
|
size=skill.size,
|
||||||
|
mime_type=skill.mime_type,
|
||||||
|
)
|
||||||
|
for skill in available_skills
|
||||||
|
]
|
||||||
|
seen_skill_names = {skill.name for skill in skill_configs}
|
||||||
|
for skill in runtime_config_skills:
|
||||||
|
if skill.name in seen_skill_names:
|
||||||
|
continue
|
||||||
|
seen_skill_names.add(skill.name)
|
||||||
|
skill_configs.append(skill)
|
||||||
available_files = [file_ref for file_ref in agent_soul.config_files if not file_ref.is_missing]
|
available_files = [file_ref for file_ref in agent_soul.config_files if not file_ref.is_missing]
|
||||||
skill_names = {skill.name for skill in available_skills}
|
skill_names = {skill.name for skill in skill_configs}
|
||||||
file_names = {file_ref.name for file_ref in available_files}
|
file_names = {file_ref.name for file_ref in available_files}
|
||||||
warnings: list[dict[str, str]] = [
|
warnings: list[dict[str, str]] = [
|
||||||
{
|
{
|
||||||
@@ -960,15 +1016,7 @@ def build_config_layer_config(
|
|||||||
kind=config_version_kind,
|
kind=config_version_kind,
|
||||||
writable=config_version_kind == "build_draft",
|
writable=config_version_kind == "build_draft",
|
||||||
),
|
),
|
||||||
skills=[
|
skills=skill_configs,
|
||||||
DifyConfigSkillConfig(
|
|
||||||
name=skill.name,
|
|
||||||
description=skill.description,
|
|
||||||
size=skill.size,
|
|
||||||
mime_type=skill.mime_type,
|
|
||||||
)
|
|
||||||
for skill in available_skills
|
|
||||||
],
|
|
||||||
files=[
|
files=[
|
||||||
DifyConfigFileConfig(
|
DifyConfigFileConfig(
|
||||||
name=file_ref.name,
|
name=file_ref.name,
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""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())
|
||||||
@@ -1,32 +1,31 @@
|
|||||||
"""Workflow Agent participant persistence keyed by node execution."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from dataclasses import dataclass, field
|
||||||
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 (
|
||||||
AgentConfigVersionKind,
|
AgentRuntimeSessionOwnerType,
|
||||||
AgentWorkingResourceStatus,
|
WorkflowAgentRuntimeSession,
|
||||||
AgentWorkspace,
|
WorkflowAgentRuntimeSessionStatus,
|
||||||
AgentWorkspaceBinding,
|
|
||||||
AgentWorkspaceOwnerType,
|
|
||||||
)
|
|
||||||
from models.workflow import WorkflowNodeExecutionModel
|
|
||||||
from services.agent.workspace_service import (
|
|
||||||
AgentWorkspaceNotFoundError,
|
|
||||||
AgentWorkspaceService,
|
|
||||||
WorkspaceOwnerScope,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_CALLER_VISIBILITY_ATTEMPTS = 60
|
_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
|
||||||
_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)
|
||||||
@@ -37,251 +36,171 @@ 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
|
||||||
workflow_agent_binding_id: str
|
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
|
||||||
binding_id: str
|
session_snapshot: CompositorSessionSnapshot
|
||||||
workspace_id: str
|
backend_run_id: str | None
|
||||||
backend_binding_ref: str
|
runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list)
|
||||||
session_snapshot: CompositorSessionSnapshot | None
|
# ENG-637: set while the session is paused on a dify.ask_human deferred call.
|
||||||
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 WorkflowAgentWorkspaceStore:
|
class WorkflowAgentRuntimeSessionStore:
|
||||||
"""Load or create the participant named by a node execution caller row."""
|
"""Stores Agent backend session snapshots for workflow Agent node re-entry."""
|
||||||
|
|
||||||
def load_existing_node_execution_scope(
|
def load_active_snapshot(self, scope: WorkflowAgentSessionScope) -> CompositorSessionSnapshot | None:
|
||||||
self,
|
stored = self.load_active_session(scope)
|
||||||
*,
|
return stored.session_snapshot if stored is not None else None
|
||||||
tenant_id: str,
|
|
||||||
app_id: str,
|
def load_active_session(self, scope: WorkflowAgentSessionScope) -> StoredWorkflowAgentSession | None:
|
||||||
workflow_id: str,
|
"""Load the active session row including any pending ask_human correlation."""
|
||||||
workflow_run_id: str | None,
|
if scope.workflow_run_id is None:
|
||||||
node_id: str,
|
return None
|
||||||
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:
|
||||||
execution = self._load_execution_by_identity(
|
row = session.scalar(
|
||||||
session=session,
|
select(WorkflowAgentRuntimeSession).where(
|
||||||
tenant_id=tenant_id,
|
WorkflowAgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||||
app_id=app_id,
|
WorkflowAgentRuntimeSession.workflow_run_id == scope.workflow_run_id,
|
||||||
workflow_id=workflow_id,
|
WorkflowAgentRuntimeSession.node_id == scope.node_id,
|
||||||
workflow_run_id=workflow_run_id,
|
WorkflowAgentRuntimeSession.binding_id == scope.binding_id,
|
||||||
node_id=node_id,
|
WorkflowAgentRuntimeSession.agent_id == scope.agent_id,
|
||||||
node_execution_id=node_execution_id,
|
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
binding_id = execution.agent_workspace_binding_id
|
if row is None:
|
||||||
if binding_id is None:
|
|
||||||
return None
|
return None
|
||||||
process_data = execution.process_data_dict
|
return StoredWorkflowAgentSession(
|
||||||
if not isinstance(process_data, dict):
|
scope=scope,
|
||||||
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is invalid")
|
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||||
workflow_agent_binding_id = process_data.get("workflow_agent_binding_id")
|
backend_run_id=row.backend_run_id,
|
||||||
if not isinstance(workflow_agent_binding_id, str):
|
runtime_layer_specs=_deserialize_specs(row.composition_layer_specs),
|
||||||
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is missing")
|
pending_form_id=row.pending_form_id,
|
||||||
owner_scope = WorkspaceOwnerScope(
|
pending_tool_call_id=row.pending_tool_call_id,
|
||||||
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 load_or_create_node_execution_session(
|
def list_active_sessions(self, *, workflow_run_id: str) -> list[StoredWorkflowAgentSession]:
|
||||||
self, scope: WorkflowAgentSessionScope, *, home_snapshot_id: str
|
|
||||||
) -> StoredWorkflowAgentSession:
|
|
||||||
with session_factory.create_session() as session:
|
with session_factory.create_session() as session:
|
||||||
execution = self._load_execution(session=session, scope=scope)
|
rows = session.scalars(
|
||||||
process_data = execution.process_data_dict
|
select(WorkflowAgentRuntimeSession).where(
|
||||||
if process_data is None:
|
WorkflowAgentRuntimeSession.workflow_run_id == workflow_run_id,
|
||||||
process_data = {}
|
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
execution.agent_workspace_binding_id = binding.id
|
).all()
|
||||||
execution.process_data = json.dumps(
|
return [
|
||||||
{
|
StoredWorkflowAgentSession(
|
||||||
**process_data,
|
scope=WorkflowAgentSessionScope(
|
||||||
"workflow_agent_binding_id": scope.workflow_agent_binding_id,
|
tenant_id=row.tenant_id,
|
||||||
},
|
app_id=row.app_id,
|
||||||
ensure_ascii=False,
|
# These columns are nullable on the unified runtime-session
|
||||||
|
# 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),
|
||||||
)
|
)
|
||||||
session.commit()
|
for row in rows
|
||||||
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,
|
||||||
binding_id: str,
|
backend_run_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 snapshot is None:
|
if scope.workflow_run_id is None or 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
def retire_workflow_run(self, *, tenant_id: str, app_id: str, workflow_run_id: str) -> list[str]:
|
snapshot_json = snapshot.model_dump_json()
|
||||||
"""Retire active Workspaces, commit, and return active or already-retired IDs for collection."""
|
specs_json = _serialize_specs(runtime_layer_specs)
|
||||||
|
|
||||||
retired: list[str] = []
|
|
||||||
with session_factory.create_session() as session:
|
with session_factory.create_session() as session:
|
||||||
workspaces = session.scalars(
|
row = session.scalar(
|
||||||
select(AgentWorkspace).where(
|
select(WorkflowAgentRuntimeSession).where(
|
||||||
AgentWorkspace.tenant_id == tenant_id,
|
WorkflowAgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||||
AgentWorkspace.app_id == app_id,
|
WorkflowAgentRuntimeSession.workflow_run_id == scope.workflow_run_id,
|
||||||
AgentWorkspace.owner_type == AgentWorkspaceOwnerType.WORKFLOW_RUN,
|
WorkflowAgentRuntimeSession.node_id == scope.node_id,
|
||||||
AgentWorkspace.owner_id == workflow_run_id,
|
WorkflowAgentRuntimeSession.binding_id == scope.binding_id,
|
||||||
AgentWorkspace.status.in_((AgentWorkingResourceStatus.ACTIVE, AgentWorkingResourceStatus.RETIRED)),
|
WorkflowAgentRuntimeSession.agent_id == scope.agent_id,
|
||||||
)
|
)
|
||||||
).all()
|
)
|
||||||
for workspace in workspaces:
|
if row is None:
|
||||||
if workspace.status == AgentWorkingResourceStatus.RETIRED:
|
row = WorkflowAgentRuntimeSession(
|
||||||
retired.append(workspace.id)
|
tenant_id=scope.tenant_id,
|
||||||
continue
|
app_id=scope.app_id,
|
||||||
workspace_id = AgentWorkspaceService.retire_workspace(
|
owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
|
||||||
session=session,
|
workflow_id=scope.workflow_id,
|
||||||
tenant_id=tenant_id,
|
workflow_run_id=scope.workflow_run_id,
|
||||||
workspace_id=workspace.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,
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
if workspace_id is not None:
|
session.add(row)
|
||||||
retired.append(workspace_id)
|
else:
|
||||||
|
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
|
|
||||||
|
|
||||||
@staticmethod
|
def mark_cleaned(self, *, scope: WorkflowAgentSessionScope, backend_run_id: str | None = None) -> None:
|
||||||
def _load_execution(*, session: Session, scope: WorkflowAgentSessionScope) -> WorkflowNodeExecutionModel:
|
if scope.workflow_run_id is None:
|
||||||
return WorkflowAgentWorkspaceStore._load_execution_by_identity(
|
return
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
with session_factory.create_session() as session:
|
||||||
def _load_execution_by_identity(
|
row = session.scalar(
|
||||||
*,
|
select(WorkflowAgentRuntimeSession).where(
|
||||||
session: Session,
|
WorkflowAgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||||
tenant_id: str,
|
WorkflowAgentRuntimeSession.workflow_run_id == scope.workflow_run_id,
|
||||||
app_id: str,
|
WorkflowAgentRuntimeSession.node_id == scope.node_id,
|
||||||
workflow_id: str,
|
WorkflowAgentRuntimeSession.binding_id == scope.binding_id,
|
||||||
workflow_run_id: str | None,
|
WorkflowAgentRuntimeSession.agent_id == scope.agent_id,
|
||||||
node_id: str,
|
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE,
|
||||||
node_execution_id: str,
|
)
|
||||||
) -> WorkflowNodeExecutionModel:
|
)
|
||||||
"""Wait briefly for the already-emitted node-start event to persist its caller row."""
|
if row is None:
|
||||||
|
return
|
||||||
stmt = select(WorkflowNodeExecutionModel).where(
|
if backend_run_id is not None:
|
||||||
WorkflowNodeExecutionModel.id == node_execution_id,
|
row.backend_run_id = backend_run_id
|
||||||
WorkflowNodeExecutionModel.tenant_id == tenant_id,
|
row.status = WorkflowAgentRuntimeSessionStatus.CLEANED
|
||||||
WorkflowNodeExecutionModel.app_id == app_id,
|
row.cleaned_at = naive_utc_now()
|
||||||
WorkflowNodeExecutionModel.workflow_id == workflow_id,
|
session.commit()
|
||||||
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__ = ["StoredWorkflowAgentSession", "WorkflowAgentSessionScope", "WorkflowAgentWorkspaceStore"]
|
__all__ = [
|
||||||
|
"StoredWorkflowAgentSession",
|
||||||
|
"WorkflowAgentRuntimeSessionStore",
|
||||||
|
"WorkflowAgentSessionScope",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -166,7 +166,6 @@ 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,12 +277,6 @@ 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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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) # guard-ignore: no-new-getattr -- delegates model fields
|
return getattr(self._source, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||||
|
|
||||||
|
|
||||||
class _FeedbackResponseSource(_SessionResponseSource[MessageFeedback]):
|
class _FeedbackResponseSource(_SessionResponseSource[MessageFeedback]):
|
||||||
|
|||||||
@@ -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) # guard-ignore: no-new-getattr -- delegates model fields
|
return getattr(self.dataset, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||||
|
|
||||||
|
|
||||||
def dataset_detail_response_source(dataset: Any, *, session: Session) -> DatasetDetailResponseSource:
|
def dataset_detail_response_source(dataset: Any, *, session: Session) -> DatasetDetailResponseSource:
|
||||||
|
|||||||
@@ -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) # guard-ignore: no-new-getattr -- delegates model fields
|
return getattr(self.document, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||||
|
|
||||||
|
|
||||||
def document_response(document: Document, *, session: Session) -> DocumentResponse:
|
def document_response(document: Document, *, session: Session) -> DocumentResponse:
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
"""add workspace skill management
|
||||||
|
|
||||||
|
Revision ID: a4f8d2c9e1b0
|
||||||
|
Revises: 6f5a9c2d8e1b
|
||||||
|
Create Date: 2026-07-09 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
from models.types import StringUUID
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "a4f8d2c9e1b0"
|
||||||
|
down_revision = "6f5a9c2d8e1b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_column(name: str, *, nullable: bool = False) -> sa.Column:
|
||||||
|
return sa.Column(name, StringUUID(), nullable=nullable)
|
||||||
|
|
||||||
|
|
||||||
|
def _long_text() -> sa.types.TypeEngine:
|
||||||
|
return sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"skills",
|
||||||
|
_uuid_column("id"),
|
||||||
|
_uuid_column("tenant_id"),
|
||||||
|
sa.Column("name", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("icon", sa.String(length=16), nullable=False, server_default="📄"),
|
||||||
|
sa.Column("description", sa.String(length=1024), nullable=False, server_default=""),
|
||||||
|
sa.Column("name_manually_edited", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("visibility", sa.String(length=32), nullable=False, server_default="workspace"),
|
||||||
|
_uuid_column("latest_published_version_id", nullable=True),
|
||||||
|
_uuid_column("created_by", nullable=True),
|
||||||
|
_uuid_column("updated_by", nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||||
|
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
|
||||||
|
)
|
||||||
|
op.create_index("skills_tenant_updated_at_idx", "skills", ["tenant_id", "updated_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"skill_draft_files",
|
||||||
|
_uuid_column("id"),
|
||||||
|
_uuid_column("skill_id"),
|
||||||
|
sa.Column("path", sa.String(length=512), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("storage", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("mime_type", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("content_text", _long_text(), nullable=True),
|
||||||
|
_uuid_column("tool_file_id", nullable=True),
|
||||||
|
sa.Column("size", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("hash", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||||
|
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
|
||||||
|
sa.UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
|
||||||
|
)
|
||||||
|
op.create_index("skill_draft_files_skill_path_idx", "skill_draft_files", ["skill_id", "path"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"skill_versions",
|
||||||
|
_uuid_column("id"),
|
||||||
|
_uuid_column("skill_id"),
|
||||||
|
sa.Column("version_number", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("version_name", sa.String(length=128), nullable=False, server_default=""),
|
||||||
|
sa.Column("publish_note", sa.String(length=1024), nullable=False, server_default=""),
|
||||||
|
sa.Column("manifest", _long_text(), nullable=False),
|
||||||
|
_uuid_column("archive_tool_file_id"),
|
||||||
|
sa.Column("hash_code", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("archive_size", sa.BigInteger(), nullable=False),
|
||||||
|
_uuid_column("published_by", nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||||
|
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
|
||||||
|
sa.UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
|
||||||
|
)
|
||||||
|
op.create_index("skill_versions_skill_created_at_idx", "skill_versions", ["skill_id", "created_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"agent_skill_bindings",
|
||||||
|
_uuid_column("id"),
|
||||||
|
_uuid_column("tenant_id"),
|
||||||
|
_uuid_column("agent_id"),
|
||||||
|
_uuid_column("skill_id"),
|
||||||
|
sa.Column("priority", sa.Integer(), nullable=False),
|
||||||
|
_uuid_column("created_by", nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||||
|
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
|
||||||
|
)
|
||||||
|
op.create_index("agent_skill_bindings_skill_idx", "agent_skill_bindings", ["tenant_id", "skill_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("agent_skill_bindings_skill_idx", table_name="agent_skill_bindings")
|
||||||
|
op.drop_table("agent_skill_bindings")
|
||||||
|
op.drop_index("skill_versions_skill_created_at_idx", table_name="skill_versions")
|
||||||
|
op.drop_table("skill_versions")
|
||||||
|
op.drop_index("skill_draft_files_skill_path_idx", table_name="skill_draft_files")
|
||||||
|
op.drop_table("skill_draft_files")
|
||||||
|
op.drop_index("skills_tenant_updated_at_idx", table_name="skills")
|
||||||
|
op.drop_table("skills")
|
||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
"""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 ###
|
|
||||||
+110
-23
@@ -18,31 +18,109 @@ branch_labels = None
|
|||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pg(conn) -> bool:
|
||||||
|
return conn.dialect.name == "postgresql"
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
|
||||||
|
kwargs = {"nullable": nullable, "primary_key": primary_key}
|
||||||
|
if primary_key and _is_pg(op.get_bind()):
|
||||||
|
kwargs["server_default"] = sa.text("uuidv7()")
|
||||||
|
return sa.Column(name, models.types.StringUUID(), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return sa.inspect(op.get_bind()).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
return any(
|
||||||
|
column["name"] == column_name for column in sa.inspect(op.get_bind()).get_columns(table_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_unique_constraint(table_name: str, constraint_name: str) -> bool:
|
||||||
|
return any(
|
||||||
|
constraint["name"] == constraint_name
|
||||||
|
for constraint in sa.inspect(op.get_bind()).get_unique_constraints(table_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
|
if not _has_table("agent_debug_conversations"):
|
||||||
|
op.create_table(
|
||||||
|
"agent_debug_conversations",
|
||||||
|
_uuid_column("id", primary_key=True),
|
||||||
|
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||||
|
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
|
||||||
|
sa.Column("app_id", models.types.StringUUID(), nullable=False),
|
||||||
|
sa.Column("account_id", models.types.StringUUID(), nullable=False),
|
||||||
|
sa.Column("conversation_id", models.types.StringUUID(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"draft_type",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'debug_build'"),
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("agent_debug_conversation_pkey")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"agent_id",
|
||||||
|
"account_id",
|
||||||
|
"draft_type",
|
||||||
|
name=op.f("agent_debug_conversation_agent_account_draft_type_unique"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"agent_debug_conversation_conversation_idx",
|
||||||
|
"agent_debug_conversations",
|
||||||
|
["conversation_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"agent_debug_conversation_account_idx",
|
||||||
|
"agent_debug_conversations",
|
||||||
|
["tenant_id", "account_id"],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Existing pointers have always represented Build chat because the Agent
|
# Existing pointers have always represented Build chat because the Agent
|
||||||
# detail API exposes them as ``debug_conversation_id`` for that surface.
|
# detail API exposes them as ``debug_conversation_id`` for that surface.
|
||||||
op.add_column(
|
if not _has_column("agent_debug_conversations", "draft_type"):
|
||||||
|
op.add_column(
|
||||||
|
"agent_debug_conversations",
|
||||||
|
sa.Column(
|
||||||
|
"draft_type",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'debug_build'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if _has_unique_constraint(
|
||||||
"agent_debug_conversations",
|
"agent_debug_conversations",
|
||||||
sa.Column(
|
|
||||||
"draft_type",
|
|
||||||
sa.String(length=32),
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("'debug_build'"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.drop_constraint(
|
|
||||||
"agent_debug_conversation_agent_account_unique",
|
"agent_debug_conversation_agent_account_unique",
|
||||||
|
):
|
||||||
|
op.drop_constraint(
|
||||||
|
"agent_debug_conversation_agent_account_unique",
|
||||||
|
"agent_debug_conversations",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
if not _has_unique_constraint(
|
||||||
"agent_debug_conversations",
|
"agent_debug_conversations",
|
||||||
type_="unique",
|
|
||||||
)
|
|
||||||
op.create_unique_constraint(
|
|
||||||
"agent_debug_conversation_agent_account_draft_type_unique",
|
"agent_debug_conversation_agent_account_draft_type_unique",
|
||||||
"agent_debug_conversations",
|
):
|
||||||
["tenant_id", "agent_id", "account_id", "draft_type"],
|
op.create_unique_constraint(
|
||||||
)
|
"agent_debug_conversation_agent_account_draft_type_unique",
|
||||||
|
"agent_debug_conversations",
|
||||||
|
["tenant_id", "agent_id", "account_id", "draft_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
if not _has_table("agent_debug_conversations"):
|
||||||
|
return
|
||||||
|
|
||||||
debug_conversations = sa.table(
|
debug_conversations = sa.table(
|
||||||
"agent_debug_conversations",
|
"agent_debug_conversations",
|
||||||
sa.column("tenant_id", models.types.StringUUID()),
|
sa.column("tenant_id", models.types.StringUUID()),
|
||||||
@@ -64,14 +142,23 @@ def downgrade():
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
op.drop_constraint(
|
if _has_unique_constraint(
|
||||||
|
"agent_debug_conversations",
|
||||||
"agent_debug_conversation_agent_account_draft_type_unique",
|
"agent_debug_conversation_agent_account_draft_type_unique",
|
||||||
|
):
|
||||||
|
op.drop_constraint(
|
||||||
|
"agent_debug_conversation_agent_account_draft_type_unique",
|
||||||
|
"agent_debug_conversations",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
if not _has_unique_constraint(
|
||||||
"agent_debug_conversations",
|
"agent_debug_conversations",
|
||||||
type_="unique",
|
|
||||||
)
|
|
||||||
op.create_unique_constraint(
|
|
||||||
"agent_debug_conversation_agent_account_unique",
|
"agent_debug_conversation_agent_account_unique",
|
||||||
"agent_debug_conversations",
|
):
|
||||||
["tenant_id", "agent_id", "account_id"],
|
op.create_unique_constraint(
|
||||||
)
|
"agent_debug_conversation_agent_account_unique",
|
||||||
op.drop_column("agent_debug_conversations", "draft_type")
|
"agent_debug_conversations",
|
||||||
|
["tenant_id", "agent_id", "account_id"],
|
||||||
|
)
|
||||||
|
if _has_column("agent_debug_conversations", "draft_type"):
|
||||||
|
op.drop_column("agent_debug_conversations", "draft_type")
|
||||||
|
|||||||
-153
@@ -1,153 +0,0 @@
|
|||||||
"""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 ###
|
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
"""merge skill and agent debug conversation heads
|
||||||
|
|
||||||
|
Revision ID: e9f4a1b2c3d5
|
||||||
|
Revises: a4f8d2c9e1b0, d2825e7b9c10
|
||||||
|
Create Date: 2026-07-23 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "e9f4a1b2c3d5"
|
||||||
|
down_revision = ("a4f8d2c9e1b0", "d2825e7b9c10")
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
+17
-12
@@ -15,22 +15,21 @@ 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 (
|
||||||
@@ -114,6 +113,7 @@ from .provider import (
|
|||||||
TenantDefaultModel,
|
TenantDefaultModel,
|
||||||
TenantPreferredModelProvider,
|
TenantPreferredModelProvider,
|
||||||
)
|
)
|
||||||
|
from .skill import AgentSkillBinding, Skill, SkillDraftFile, SkillFileKind, SkillFileStorage, SkillVersion
|
||||||
from .snippet import CustomizedSnippet, SnippetType
|
from .snippet import CustomizedSnippet, SnippetType
|
||||||
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||||
from .task import CeleryTask, CeleryTaskSet
|
from .task import CeleryTask, CeleryTaskSet
|
||||||
@@ -165,20 +165,18 @@ __all__ = [
|
|||||||
"AgentConfigRevision",
|
"AgentConfigRevision",
|
||||||
"AgentConfigRevisionOperation",
|
"AgentConfigRevisionOperation",
|
||||||
"AgentConfigSnapshot",
|
"AgentConfigSnapshot",
|
||||||
"AgentConfigVersionKind",
|
|
||||||
"AgentDebugConversation",
|
"AgentDebugConversation",
|
||||||
"AgentDriveFile",
|
"AgentDriveFile",
|
||||||
"AgentDriveFileKind",
|
"AgentDriveFileKind",
|
||||||
"AgentHomeSnapshot",
|
|
||||||
"AgentIconType",
|
"AgentIconType",
|
||||||
"AgentKind",
|
"AgentKind",
|
||||||
|
"AgentRuntimeSession",
|
||||||
|
"AgentRuntimeSessionOwnerType",
|
||||||
|
"AgentRuntimeSessionStatus",
|
||||||
"AgentScope",
|
"AgentScope",
|
||||||
|
"AgentSkillBinding",
|
||||||
"AgentSource",
|
"AgentSource",
|
||||||
"AgentStatus",
|
"AgentStatus",
|
||||||
"AgentWorkingResourceStatus",
|
|
||||||
"AgentWorkspace",
|
|
||||||
"AgentWorkspaceBinding",
|
|
||||||
"AgentWorkspaceOwnerType",
|
|
||||||
"ApiRequest",
|
"ApiRequest",
|
||||||
"ApiToken",
|
"ApiToken",
|
||||||
"ApiToolProvider",
|
"ApiToolProvider",
|
||||||
@@ -250,6 +248,11 @@ __all__ = [
|
|||||||
"RecommendedApp",
|
"RecommendedApp",
|
||||||
"SavedMessage",
|
"SavedMessage",
|
||||||
"Site",
|
"Site",
|
||||||
|
"Skill",
|
||||||
|
"SkillDraftFile",
|
||||||
|
"SkillFileKind",
|
||||||
|
"SkillFileStorage",
|
||||||
|
"SkillVersion",
|
||||||
"SnippetType",
|
"SnippetType",
|
||||||
"Tag",
|
"Tag",
|
||||||
"TagBinding",
|
"TagBinding",
|
||||||
@@ -275,6 +278,8 @@ __all__ = [
|
|||||||
"Workflow",
|
"Workflow",
|
||||||
"WorkflowAgentBindingType",
|
"WorkflowAgentBindingType",
|
||||||
"WorkflowAgentNodeBinding",
|
"WorkflowAgentNodeBinding",
|
||||||
|
"WorkflowAgentRuntimeSession",
|
||||||
|
"WorkflowAgentRuntimeSessionStatus",
|
||||||
"WorkflowAppLog",
|
"WorkflowAppLog",
|
||||||
"WorkflowAppLogCreatedFrom",
|
"WorkflowAppLogCreatedFrom",
|
||||||
"WorkflowArchiveLog",
|
"WorkflowArchiveLog",
|
||||||
|
|||||||
+104
-114
@@ -116,29 +116,35 @@ class WorkflowAgentBindingType(StrEnum):
|
|||||||
INLINE_AGENT = "inline_agent"
|
INLINE_AGENT = "inline_agent"
|
||||||
|
|
||||||
|
|
||||||
class AgentWorkingResourceStatus(StrEnum):
|
class AgentRuntimeSessionStatus(StrEnum):
|
||||||
"""Product lifecycle state for a persistent working-environment resource."""
|
"""Lifecycle state of an Agent backend session snapshot.
|
||||||
|
|
||||||
|
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"
|
||||||
RETIRED = "retired"
|
# Snapshot has been retired and must not be submitted to Agent backend again.
|
||||||
|
CLEANED = "cleaned"
|
||||||
|
|
||||||
|
|
||||||
class AgentWorkspaceOwnerType(StrEnum):
|
class AgentRuntimeSessionOwnerType(StrEnum):
|
||||||
"""Product scope that owns a Workspace."""
|
"""Which product surface owns an Agent runtime session row."""
|
||||||
|
|
||||||
|
# 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"
|
|
||||||
|
|
||||||
|
|
||||||
class AgentConfigVersionKind(StrEnum):
|
# Back-compat alias: the workflow lifecycle code (shipped in PR #36724) imports
|
||||||
SNAPSHOT = "snapshot"
|
# the old name. Kept so unifying the table does not churn that path.
|
||||||
DRAFT = "draft"
|
WorkflowAgentRuntimeSessionStatus = AgentRuntimeSessionStatus
|
||||||
BUILD_DRAFT = "build_draft"
|
|
||||||
|
|
||||||
|
|
||||||
class Agent(DefaultFieldsMixin, Base):
|
class Agent(DefaultFieldsMixin, Base):
|
||||||
"""Agent Soul and source lineage; ``AgentWorkspaceBinding.id`` identifies each materialized participant."""
|
"""Workspace-scoped Agent identity used by Agent Roster and workflow-only agents."""
|
||||||
|
|
||||||
__tablename__ = "agents"
|
__tablename__ = "agents"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -215,42 +221,14 @@ 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 AgentHomeSnapshot(Base):
|
|
||||||
"""Append-only mapping from one Agent-owned Home identity to its backend ref.
|
|
||||||
|
|
||||||
Product tables reference ``id``. ``snapshot_ref`` remains an opaque
|
|
||||||
deployment-specific handle and is only consumed at Dify Agent boundaries.
|
|
||||||
Snapshot bytes and ``snapshot_ref`` are immutable. Lifecycle metadata can
|
|
||||||
transition ACTIVE -> RETIRED; successful physical collection deletes row.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__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):
|
class AgentDebugConversation(DefaultFieldsMixin, Base):
|
||||||
"""Current console Conversation pointer for one account and draft surface.
|
"""Per-account, per-draft console debug conversation for an Agent App.
|
||||||
|
|
||||||
This row owns no Binding or runtime. A Preview Conversation holds its
|
Agent App preview state must be isolated by editor account. The Agent row is
|
||||||
CONVERSATION Binding pointer, while a DEBUG_BUILD AgentConfigDraft holds its
|
shared by everyone in the workspace, so this table owns the user-specific
|
||||||
BUILD_DRAFT Binding pointer.
|
conversation pointers used by console debug chat. ``draft`` is the Preview
|
||||||
|
conversation and ``debug_build`` is the Build conversation; they must never
|
||||||
|
share persisted messages or runtime sessions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "agent_debug_conversations"
|
__tablename__ = "agent_debug_conversations"
|
||||||
@@ -281,11 +259,7 @@ 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__ = (
|
||||||
@@ -307,8 +281,6 @@ 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)
|
||||||
@@ -344,7 +316,6 @@ 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)
|
||||||
@@ -461,83 +432,102 @@ class WorkflowAgentNodeBinding(DefaultFieldsMixin, Base):
|
|||||||
return dict(self.node_job_config)
|
return dict(self.node_job_config)
|
||||||
|
|
||||||
|
|
||||||
class AgentWorkspace(DefaultFieldsMixin, Base):
|
class AgentRuntimeSession(DefaultFieldsMixin, Base):
|
||||||
"""Mutable Workspace owned by one product scope, independent of Agents."""
|
"""Persisted Agent backend session snapshot, owner-agnostic.
|
||||||
|
|
||||||
__tablename__ = "agent_workspaces"
|
One unified table serves both owners (decision Q2):
|
||||||
__table_args__ = (
|
- workflow Agent Node runs: ``owner_type = workflow_run``; the
|
||||||
sa.PrimaryKeyConstraint("id", name="agent_workspace_pkey"),
|
``workflow_id / workflow_run_id / node_id / binding_id /
|
||||||
Index(
|
agent_config_snapshot_id / composition_layer_specs`` columns are set.
|
||||||
"agent_workspace_owner_active_unique",
|
- Agent App conversations: ``owner_type = conversation``; the
|
||||||
"tenant_id",
|
``conversation_id`` column is set and the workflow columns stay NULL.
|
||||||
"owner_type",
|
Runtime state is scoped by ``agent_config_snapshot_id``. For published
|
||||||
"owner_id",
|
web/API runs this points to an immutable AgentConfigSnapshot; for console
|
||||||
"owner_scope_key",
|
debugger/build runs it points to the editable AgentConfigDraft row.
|
||||||
"active_guard",
|
|
||||||
unique=True,
|
|
||||||
),
|
|
||||||
Index("agent_workspace_tenant_status_idx", "tenant_id", "status"),
|
|
||||||
Index("agent_workspace_tenant_app_status_idx", "tenant_id", "app_id", "status"),
|
|
||||||
Index("agent_workspace_status_retired_idx", "status", "retired_at"),
|
|
||||||
)
|
|
||||||
|
|
||||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
The snapshot is runtime state returned by Agent backend, kept separate from
|
||||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
Agent Soul snapshots and workflow node-job config.
|
||||||
owner_type: Mapped[AgentWorkspaceOwnerType] = mapped_column(
|
|
||||||
EnumText(AgentWorkspaceOwnerType, length=32), nullable=False
|
|
||||||
)
|
|
||||||
owner_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
|
||||||
owner_scope_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
backend_workspace_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,
|
|
||||||
)
|
|
||||||
active_guard: Mapped[int | None] = mapped_column(sa.SmallInteger, nullable=True, default=1, server_default="1")
|
|
||||||
retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
__tablename__ = "agent_runtime_sessions"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
sa.PrimaryKeyConstraint("id", name="agent_workspace_binding_pkey"),
|
sa.PrimaryKeyConstraint("id", name="agent_runtime_session_pkey"),
|
||||||
Index("agent_workspace_binding_workspace_status_idx", "tenant_id", "workspace_id", "status"),
|
# Workflow owner uniqueness (partial: only rows with a workflow_run_id).
|
||||||
Index("agent_workspace_binding_agent_status_idx", "tenant_id", "agent_id", "status"),
|
Index(
|
||||||
Index("agent_workspace_binding_status_retired_idx", "status", "retired_at"),
|
"agent_runtime_session_workflow_scope_unique",
|
||||||
|
"tenant_id",
|
||||||
|
"workflow_run_id",
|
||||||
|
"node_id",
|
||||||
|
"binding_id",
|
||||||
|
"agent_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
# Conversation owner uniqueness (partial: only rows with a conversation_id).
|
||||||
|
Index(
|
||||||
|
"agent_runtime_session_conversation_scope_unique",
|
||||||
|
"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)
|
||||||
workspace_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
owner_type: Mapped[AgentRuntimeSessionOwnerType] = mapped_column(
|
||||||
|
EnumText(AgentRuntimeSessionOwnerType, length=32), nullable=False
|
||||||
|
)
|
||||||
agent_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)
|
backend_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
agent_config_version_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
session_snapshot: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||||
agent_config_version_kind: Mapped[AgentConfigVersionKind] = mapped_column(
|
# Workflow-owner columns (NULL for conversation owner).
|
||||||
EnumText(AgentConfigVersionKind, length=32), nullable=False
|
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||||
)
|
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||||
backend_binding_ref: Mapped[str] = mapped_column(String(255), nullable=False)
|
node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
session_snapshot: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
status: Mapped[AgentWorkingResourceStatus] = mapped_column(
|
binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||||
EnumText(AgentWorkingResourceStatus, length=32),
|
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=AgentWorkingResourceStatus.ACTIVE,
|
default=AgentRuntimeSessionStatus.ACTIVE,
|
||||||
server_default=AgentWorkingResourceStatus.ACTIVE.value,
|
|
||||||
)
|
)
|
||||||
retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
cleaned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
# ENG-637: when a run pauses for a dify.ask_human deferred call, these link
|
||||||
|
# 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.
|
||||||
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."""
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ class TagType(StrEnum):
|
|||||||
KNOWLEDGE = "knowledge"
|
KNOWLEDGE = "knowledge"
|
||||||
APP = "app"
|
APP = "app"
|
||||||
SNIPPET = "snippet"
|
SNIPPET = "snippet"
|
||||||
|
SKILL = "skill"
|
||||||
|
|
||||||
|
|
||||||
class DatasetMetadataType(StrEnum):
|
class DatasetMetadataType(StrEnum):
|
||||||
|
|||||||
+1
-9
@@ -1160,13 +1160,6 @@ 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"),
|
||||||
@@ -1188,7 +1181,6 @@ 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)
|
||||||
@@ -2675,7 +2667,7 @@ class Tag(TypeBase):
|
|||||||
sa.Index("tag_name_idx", "name"),
|
sa.Index("tag_name_idx", "name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
TAG_TYPE_LIST = ["knowledge", "app", "snippet"]
|
TAG_TYPE_LIST = ["knowledge", "app", "snippet", "skill"]
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(
|
id: Mapped[str] = mapped_column(
|
||||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Workspace-level Skill Management models.
|
||||||
|
|
||||||
|
These tables are the source of truth for reusable workspace Skills. Agent Soul
|
||||||
|
``config_skills`` and Agent Drive skill rows remain per-agent runtime/config
|
||||||
|
assets; they may consume a published Skill snapshot but do not own the Skill's
|
||||||
|
draft, metadata, version history, or Agent binding priority.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import Index, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from models.base import Base, DefaultFieldsMixin
|
||||||
|
from models.types import EnumText, JSONModelColumn, LongText, StringUUID
|
||||||
|
|
||||||
|
|
||||||
|
class SkillFileKind(StrEnum):
|
||||||
|
"""Draft file entry kind."""
|
||||||
|
|
||||||
|
FILE = "file"
|
||||||
|
DIRECTORY = "directory"
|
||||||
|
|
||||||
|
|
||||||
|
class SkillFileStorage(StrEnum):
|
||||||
|
"""How a draft file's content is stored."""
|
||||||
|
|
||||||
|
TEXT = "text"
|
||||||
|
TOOL_FILE = "tool_file"
|
||||||
|
|
||||||
|
|
||||||
|
class SkillVersionManifestFile(BaseModel):
|
||||||
|
"""One file entry captured in a published Skill snapshot manifest."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
mime_type: str | None = None
|
||||||
|
size: int
|
||||||
|
hash: str
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillVersionManifest(BaseModel):
|
||||||
|
"""Published Skill snapshot file index."""
|
||||||
|
|
||||||
|
files: list[SkillVersionManifestFile]
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class Skill(DefaultFieldsMixin, Base):
|
||||||
|
"""Workspace-level reusable Skill metadata and draft status."""
|
||||||
|
|
||||||
|
__tablename__ = "skills"
|
||||||
|
__table_args__ = (
|
||||||
|
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
|
||||||
|
UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
|
||||||
|
Index("skills_tenant_updated_at_idx", "tenant_id", "updated_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(sa.String(64), nullable=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(sa.String(128), nullable=False)
|
||||||
|
icon: Mapped[str] = mapped_column(sa.String(16), nullable=False, default="📄", server_default="📄")
|
||||||
|
description: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
|
||||||
|
name_manually_edited: Mapped[bool] = mapped_column(
|
||||||
|
sa.Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
)
|
||||||
|
visibility: Mapped[str] = mapped_column(
|
||||||
|
sa.String(32),
|
||||||
|
nullable=False,
|
||||||
|
default="workspace",
|
||||||
|
server_default="workspace",
|
||||||
|
)
|
||||||
|
latest_published_version_id: 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)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillDraftFile(DefaultFieldsMixin, Base):
|
||||||
|
"""One draft file or directory in a workspace Skill."""
|
||||||
|
|
||||||
|
__tablename__ = "skill_draft_files"
|
||||||
|
__table_args__ = (
|
||||||
|
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
|
||||||
|
UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
|
||||||
|
Index("skill_draft_files_skill_path_idx", "skill_id", "path"),
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
path: Mapped[str] = mapped_column(sa.String(512), nullable=False)
|
||||||
|
kind: Mapped[SkillFileKind] = mapped_column(EnumText(SkillFileKind, length=32), nullable=False)
|
||||||
|
storage: Mapped[SkillFileStorage | None] = mapped_column(EnumText(SkillFileStorage, length=32), nullable=True)
|
||||||
|
mime_type: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
|
||||||
|
content_text: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||||
|
tool_file_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||||
|
size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
|
||||||
|
hash: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillVersion(DefaultFieldsMixin, Base):
|
||||||
|
"""Immutable published Skill snapshot.
|
||||||
|
|
||||||
|
``hash_code`` uniquely identifies a published version for downstream
|
||||||
|
execution audit. It includes Skill identity, version number, and archive
|
||||||
|
content digest instead of being only the archive content hash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "skill_versions"
|
||||||
|
__table_args__ = (
|
||||||
|
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
|
||||||
|
UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
|
||||||
|
Index("skill_versions_skill_created_at_idx", "skill_id", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
version_number: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||||
|
version_name: Mapped[str] = mapped_column(sa.String(128), nullable=False, default="", server_default="")
|
||||||
|
publish_note: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
|
||||||
|
manifest: Mapped[SkillVersionManifest] = mapped_column(JSONModelColumn(SkillVersionManifest), nullable=False)
|
||||||
|
archive_tool_file_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
hash_code: Mapped[str] = mapped_column(sa.String(255), nullable=False)
|
||||||
|
archive_size: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)
|
||||||
|
published_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSkillBinding(DefaultFieldsMixin, Base):
|
||||||
|
"""Direct Agent-to-workspace-Skill binding.
|
||||||
|
|
||||||
|
``priority`` is retained as an internal ordering column for the current
|
||||||
|
schema constraints. Runtime Skill selection is Agent-driven and must not
|
||||||
|
treat it as a matching priority.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "agent_skill_bindings"
|
||||||
|
__table_args__ = (
|
||||||
|
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
|
||||||
|
UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
|
||||||
|
UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
|
||||||
|
Index("agent_skill_bindings_skill_idx", "tenant_id", "skill_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
priority: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentSkillBinding",
|
||||||
|
"Skill",
|
||||||
|
"SkillDraftFile",
|
||||||
|
"SkillFileKind",
|
||||||
|
"SkillFileStorage",
|
||||||
|
"SkillVersion",
|
||||||
|
"SkillVersionManifest",
|
||||||
|
"SkillVersionManifestFile",
|
||||||
|
]
|
||||||
@@ -1031,7 +1031,6 @@ 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)
|
||||||
|
|||||||
@@ -964,6 +964,12 @@ 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 |
|
||||||
@@ -1255,8 +1261,7 @@ 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) |
|
||||||
| caller_id | query | Agent App caller ID | Yes | string |
|
| conversation_id | query | Agent App conversation ID | Yes | string |
|
||||||
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
|
|
||||||
|
|
||||||
#### Responses
|
#### Responses
|
||||||
|
|
||||||
@@ -1272,8 +1277,7 @@ 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) |
|
||||||
| caller_id | query | Agent App caller ID | Yes | string |
|
| conversation_id | query | Agent App conversation 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
|
||||||
@@ -1290,8 +1294,7 @@ 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) |
|
||||||
| caller_id | query | Agent App caller ID | Yes | string |
|
| conversation_id | query | Agent App conversation 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
|
||||||
@@ -3804,7 +3807,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 | Workflow node execution ID | Yes | string |
|
| node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | 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
|
||||||
@@ -3823,7 +3826,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 | Workflow node execution ID | Yes | string |
|
| node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | 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
|
||||||
@@ -9548,7 +9551,7 @@ Remove one or more tag bindings from a target.
|
|||||||
| Name | Located in | Description | Required | Schema |
|
| Name | Located in | Description | Required | Schema |
|
||||||
| ---- | ---------- | ----------- | -------- | ------ |
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
| keyword | query | Search keyword | No | string |
|
| keyword | query | Search keyword | No | string |
|
||||||
| type | query | Tag type filter | No | string, <br>**Available values:** "", "app", "knowledge", "snippet" |
|
| type | query | Tag type filter | No | string |
|
||||||
|
|
||||||
#### Responses
|
#### Responses
|
||||||
|
|
||||||
@@ -10084,6 +10087,38 @@ Get list of available agent providers
|
|||||||
| ---- | ----------- | ------ |
|
| ---- | ----------- | ------ |
|
||||||
| 200 | Success | **application/json**: [AgentProviderListResponse](#agentproviderlistresponse)<br> |
|
| 200 | Success | **application/json**: [AgentProviderListResponse](#agentproviderlistresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /workspaces/current/agents/{agent_id}/skills
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| agent_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Agent Skill bindings | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
|
||||||
|
|
||||||
|
### [PUT] /workspaces/current/agents/{agent_id}/skills
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| agent_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **application/json**: [AgentSkillBindingsPayload](#agentskillbindingspayload)<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Agent Skill bindings replaced | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
|
||||||
|
|
||||||
### [GET] /workspaces/current/customized-snippets
|
### [GET] /workspaces/current/customized-snippets
|
||||||
**List customized snippets with pagination and search**
|
**List customized snippets with pagination and search**
|
||||||
|
|
||||||
@@ -11985,6 +12020,341 @@ Returns permission flags that control workspace features like member invitations
|
|||||||
| ---- | ----------- | ------ |
|
| ---- | ----------- | ------ |
|
||||||
| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)<br> |
|
| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)<br> |
|
||||||
|
|
||||||
|
### [GET] /workspaces/current/skills
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| keyword | query | Search keyword matching skill name or description. | No | string |
|
||||||
|
| limit | query | Number of items per page. | No | integer, <br>**Default:** 20 |
|
||||||
|
| page | query | Page number. | No | integer, <br>**Default:** 1 |
|
||||||
|
| tag | query | Skill tag filters. Repeat the parameter for multiple tags. | No | [ string ] |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Workspace skills | **application/json**: [SkillListResponse](#skilllistresponse)<br> |
|
||||||
|
|
||||||
|
### [POST] /workspaces/current/skills
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **application/json**: [SkillCreatePayload](#skillcreatepayload)<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 201 | Skill created | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||||
|
|
||||||
|
### [POST] /workspaces/current/skills/files/upload
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 201 | Skill draft file uploaded | **application/json**: [SkillFileUploadResponse](#skillfileuploadresponse)<br> |
|
||||||
|
|
||||||
|
### [POST] /workspaces/current/skills/import
|
||||||
|
Import a Skill zip package from multipart form field `file`.
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 201 | Skill imported | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /workspaces/current/skills/tags
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Workspace Skill tags | **application/json**: [SkillTagListResponse](#skilltaglistresponse)<br> |
|
||||||
|
|
||||||
|
### [DELETE] /workspaces/current/skills/{skill_id}
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **application/json**: [SkillDeletePayload](#skilldeletepayload)<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill deleted | **application/json**: [SkillDeleteResponse](#skilldeleteresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /workspaces/current/skills/{skill_id}
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill detail | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||||
|
|
||||||
|
### [PATCH] /workspaces/current/skills/{skill_id}
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **application/json**: [SkillMetadataPayload](#skillmetadatapayload)<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill updated | **application/json**: [SkillResponse](#skillresponse)<br> |
|
||||||
|
|
||||||
|
### [POST] /workspaces/current/skills/{skill_id}/assist/messages
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **application/json**: [SkillAssistMessagePayload](#skillassistmessagepayload)<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| 200 | Skill Authoring assistant event stream |
|
||||||
|
|
||||||
|
### [POST] /workspaces/current/skills/{skill_id}/duplicate
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 201 | Skill duplicated | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /workspaces/current/skills/{skill_id}/export
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| 200 | Published Skill zip archive |
|
||||||
|
|
||||||
|
### [PATCH] /workspaces/current/skills/{skill_id}/files
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **application/json**: [SkillDraftFileOperationPayload](#skilldraftfileoperationpayload)<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Draft file operation applied | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||||
|
|
||||||
|
### [PUT] /workspaces/current/skills/{skill_id}/files
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **application/json**: [SkillDraftTreePayload](#skilldrafttreepayload)<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Draft files replaced | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /workspaces/current/skills/{skill_id}/files/content
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| download | query | Return as an attachment when 1. | No | string |
|
||||||
|
| path | query | Skill file path relative to the Skill root. | Yes | string |
|
||||||
|
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill file content | **application/json**: [BinaryFileResponse](#binaryfileresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /workspaces/current/skills/{skill_id}/files/preview
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| path | query | Skill file path relative to the Skill root. | Yes | string |
|
||||||
|
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill file text preview | **application/json**: [SkillFilePreviewResponse](#skillfilepreviewresponse)<br> |
|
||||||
|
|
||||||
|
### [POST] /workspaces/current/skills/{skill_id}/publish
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **application/json**: [SkillPublishPayload](#skillpublishpayload)<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill published | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /workspaces/current/skills/{skill_id}/references
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill references | **application/json**: [SkillReferenceListResponse](#skillreferencelistresponse)<br> |
|
||||||
|
|
||||||
|
### [POST] /workspaces/current/skills/{skill_id}/restore
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **application/json**: [SkillRestorePayload](#skillrestorepayload)<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill version restored | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /workspaces/current/skills/{skill_id}/versions
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill versions | **application/json**: [SkillVersionListResponse](#skillversionlistresponse)<br> |
|
||||||
|
|
||||||
|
### [DELETE] /workspaces/current/skills/{skill_id}/versions/{version_id}
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
| version_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill version deleted | **application/json**: [SkillVersionDeleteResponse](#skillversiondeleteresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /workspaces/current/skills/{skill_id}/versions/{version_id}
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
| version_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill version detail | **application/json**: [SkillVersionDetailResponse](#skillversiondetailresponse)<br> |
|
||||||
|
|
||||||
|
### [PATCH] /workspaces/current/skills/{skill_id}/versions/{version_id}
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| skill_id | path | | Yes | string |
|
||||||
|
| version_id | path | | Yes | string |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Required | Schema |
|
||||||
|
| -------- | ------ |
|
||||||
|
| Yes | **application/json**: [SkillVersionUpdatePayload](#skillversionupdatepayload)<br> |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Skill version updated | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
|
||||||
|
|
||||||
### [GET] /workspaces/current/tool-labels
|
### [GET] /workspaces/current/tool-labels
|
||||||
#### Responses
|
#### Responses
|
||||||
|
|
||||||
@@ -13940,6 +14310,12 @@ 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 |
|
||||||
@@ -14657,8 +15033,7 @@ section may be empty, which is how callers express "no knowledge layer".
|
|||||||
|
|
||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
| ---- | ---- | ----------- | -------- |
|
| ---- | ---- | ----------- | -------- |
|
||||||
| caller_id | string | Agent App caller ID | Yes |
|
| conversation_id | string | Agent App conversation 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
|
||||||
@@ -14701,6 +15076,37 @@ Visibility and lifecycle scope of an Agent record.
|
|||||||
| ---- | ---- | ----------- | -------- |
|
| ---- | ---- | ----------- | -------- |
|
||||||
| result | string | | Yes |
|
| result | string | | Yes |
|
||||||
|
|
||||||
|
#### AgentSkillBindingItemResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| description | string | | Yes |
|
||||||
|
| display_name | string | | Yes |
|
||||||
|
| file_count | integer | | Yes |
|
||||||
|
| icon | string | | Yes |
|
||||||
|
| id | string | | Yes |
|
||||||
|
| latest_published_at | integer | | No |
|
||||||
|
| latest_published_version_id | string | | No |
|
||||||
|
| name | string | | Yes |
|
||||||
|
| priority | integer | | Yes |
|
||||||
|
| status | string | | Yes |
|
||||||
|
| tags | [ string ] | | No |
|
||||||
|
| updated_at | integer | | Yes |
|
||||||
|
|
||||||
|
#### AgentSkillBindingsPayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| skill_ids | [ string ] | Ordered Skill IDs bound to the Agent. | No |
|
||||||
|
|
||||||
|
#### AgentSkillBindingsResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| agent_id | string | | Yes |
|
||||||
|
| data | [ [AgentSkillBindingItemResponse](#agentskillbindingitemresponse) ] | | No |
|
||||||
|
| skill_ids | [ string ] | | No |
|
||||||
|
|
||||||
#### AgentSkillRefConfig
|
#### AgentSkillRefConfig
|
||||||
|
|
||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
@@ -21339,6 +21745,7 @@ 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
|
||||||
@@ -21656,6 +22063,186 @@ Simple provider entity response.
|
|||||||
| title | string | | Yes |
|
| title | string | | Yes |
|
||||||
| use_icon_as_answer_icon | boolean | | Yes |
|
| use_icon_as_answer_icon | boolean | | Yes |
|
||||||
|
|
||||||
|
#### SkillAssistAttachmentPayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| mime_type | string | | No |
|
||||||
|
| name | string | | Yes |
|
||||||
|
| size | integer | | No |
|
||||||
|
| tool_file_id | string | | Yes |
|
||||||
|
|
||||||
|
#### SkillAssistMessagePayload
|
||||||
|
|
||||||
|
One user message and optional uploaded context for the read-only Skill Authoring assistant.
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| attachments | [ [SkillAssistAttachmentPayload](#skillassistattachmentpayload) ] | | No |
|
||||||
|
| message | string | | Yes |
|
||||||
|
| model | [SkillAssistModelPayload](#skillassistmodelpayload) | | No |
|
||||||
|
|
||||||
|
#### SkillAssistModelPayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| model | string | | Yes |
|
||||||
|
| model_settings | object | | No |
|
||||||
|
| plugin_id | string | | No |
|
||||||
|
| provider | string | | Yes |
|
||||||
|
|
||||||
|
#### SkillCreatePayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| description | string | | No |
|
||||||
|
| display_name | string | | No |
|
||||||
|
| icon | string, <br>**Default:** 📄 | | No |
|
||||||
|
| name | string | | No |
|
||||||
|
| tags | [ string ] | | No |
|
||||||
|
|
||||||
|
#### SkillDeletePayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| confirmation_name | string | Required when deleting a referenced Skill. Must match the Skill name. | No |
|
||||||
|
|
||||||
|
#### SkillDeleteResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| deleted | boolean | | Yes |
|
||||||
|
| id | string | | Yes |
|
||||||
|
|
||||||
|
#### SkillDetailResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| created_at | integer | | Yes |
|
||||||
|
| created_by | string | | No |
|
||||||
|
| created_by_name | string | | No |
|
||||||
|
| description | string | | Yes |
|
||||||
|
| display_name | string | | Yes |
|
||||||
|
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
|
||||||
|
| icon | string | | Yes |
|
||||||
|
| id | string | | Yes |
|
||||||
|
| latest_published_version_id | string | | No |
|
||||||
|
| name | string | | Yes |
|
||||||
|
| name_manually_edited | boolean | | No |
|
||||||
|
| reference_count | integer | | No |
|
||||||
|
| tags | [ string ] | | No |
|
||||||
|
| updated_at | integer | | Yes |
|
||||||
|
| updated_by | string | | No |
|
||||||
|
| updated_by_name | string | | No |
|
||||||
|
| visibility | string | | Yes |
|
||||||
|
|
||||||
|
#### SkillDraftFileOperation
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| SkillDraftFileOperation | string | | |
|
||||||
|
|
||||||
|
#### SkillDraftFileOperationPayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| content | string | | No |
|
||||||
|
| expected_updated_at | integer | | No |
|
||||||
|
| hash | string | | No |
|
||||||
|
| mime_type | string | | No |
|
||||||
|
| operation | [SkillDraftFileOperation](#skilldraftfileoperation) | | Yes |
|
||||||
|
| path | string | | Yes |
|
||||||
|
| size | integer | | No |
|
||||||
|
| target_path | string | | No |
|
||||||
|
| tool_file_id | string | | No |
|
||||||
|
|
||||||
|
#### SkillDraftTreeItemPayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| content | string | | No |
|
||||||
|
| hash | string | | No |
|
||||||
|
| kind | [SkillFileKind](#skillfilekind) | | No |
|
||||||
|
| mime_type | string | | No |
|
||||||
|
| path | string | | Yes |
|
||||||
|
| size | integer | | No |
|
||||||
|
| storage | [SkillFileStorage](#skillfilestorage) | | No |
|
||||||
|
| tool_file_id | string | | No |
|
||||||
|
|
||||||
|
#### SkillDraftTreePayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| expected_updated_at | integer | | No |
|
||||||
|
| files | [ [SkillDraftTreeItemPayload](#skilldrafttreeitempayload) ] | | No |
|
||||||
|
|
||||||
|
#### SkillFileKind
|
||||||
|
|
||||||
|
Draft file entry kind.
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| SkillFileKind | string | Draft file entry kind. | |
|
||||||
|
|
||||||
|
#### SkillFilePreviewResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| content | string | | Yes |
|
||||||
|
| hash | string | | Yes |
|
||||||
|
| mime_type | string | | Yes |
|
||||||
|
| path | string | | Yes |
|
||||||
|
| size | integer | | Yes |
|
||||||
|
|
||||||
|
#### SkillFileQuery
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| path | string | Skill file path relative to the Skill root. | Yes |
|
||||||
|
| version_id | string | Optional published version ID. Omit for current draft. | No |
|
||||||
|
|
||||||
|
#### SkillFileResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| content | string | | No |
|
||||||
|
| hash | string | | No |
|
||||||
|
| id | string | | No |
|
||||||
|
| kind | string | | Yes |
|
||||||
|
| mime_type | string | | No |
|
||||||
|
| path | string | | Yes |
|
||||||
|
| size | integer | | No |
|
||||||
|
| storage | string | | No |
|
||||||
|
| tool_file_id | string | | No |
|
||||||
|
|
||||||
|
#### SkillFileStorage
|
||||||
|
|
||||||
|
How a draft file's content is stored.
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| SkillFileStorage | string | How a draft file's content is stored. | |
|
||||||
|
|
||||||
|
#### SkillFileUploadResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| hash | string | | Yes |
|
||||||
|
| id | string | | Yes |
|
||||||
|
| mime_type | string | | Yes |
|
||||||
|
| name | string | | Yes |
|
||||||
|
| size | integer | | Yes |
|
||||||
|
|
||||||
|
#### SkillListResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| data | [ [SkillResponse](#skillresponse) ] | | No |
|
||||||
|
| has_more | boolean | | No |
|
||||||
|
| limit | integer, <br>**Default:** 20 | | No |
|
||||||
|
| page | integer, <br>**Default:** 1 | | No |
|
||||||
|
| total | integer | | No |
|
||||||
|
|
||||||
#### SkillManifest
|
#### SkillManifest
|
||||||
|
|
||||||
Validated metadata extracted from a Skill package.
|
Validated metadata extracted from a Skill package.
|
||||||
@@ -21669,6 +22256,91 @@ Validated metadata extracted from a Skill package.
|
|||||||
| name | string | | Yes |
|
| name | string | | Yes |
|
||||||
| size | integer | | Yes |
|
| size | integer | | Yes |
|
||||||
|
|
||||||
|
#### SkillMetadataPayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| display_name | string | | No |
|
||||||
|
| expected_updated_at | integer | | No |
|
||||||
|
| icon | string | | No |
|
||||||
|
| tags | [ string ] | | No |
|
||||||
|
|
||||||
|
#### SkillPublishPayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| publish_note | string | | No |
|
||||||
|
| version_name | string | | No |
|
||||||
|
|
||||||
|
#### SkillReferenceListResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| data | [ [SkillReferenceResponse](#skillreferenceresponse) ] | | No |
|
||||||
|
|
||||||
|
#### SkillReferenceResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| agent_icon | string | | No |
|
||||||
|
| agent_icon_background | string | | No |
|
||||||
|
| agent_icon_type | string | | No |
|
||||||
|
| agent_id | string | | Yes |
|
||||||
|
| app_id | string | | No |
|
||||||
|
| display_name | string | | Yes |
|
||||||
|
| name | string | | Yes |
|
||||||
|
| node_id | string | | No |
|
||||||
|
| node_name | string | | No |
|
||||||
|
| type | string | | Yes |
|
||||||
|
| workflow_icon | string | | No |
|
||||||
|
| workflow_icon_background | string | | No |
|
||||||
|
| workflow_icon_type | string | | No |
|
||||||
|
| workflow_id | string | | No |
|
||||||
|
| workflow_name | string | | No |
|
||||||
|
| workflow_version | string | | No |
|
||||||
|
|
||||||
|
#### SkillResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| created_at | integer | | Yes |
|
||||||
|
| created_by | string | | No |
|
||||||
|
| created_by_name | string | | No |
|
||||||
|
| description | string | | Yes |
|
||||||
|
| display_name | string | | Yes |
|
||||||
|
| icon | string | | Yes |
|
||||||
|
| id | string | | Yes |
|
||||||
|
| latest_published_version_id | string | | No |
|
||||||
|
| name | string | | Yes |
|
||||||
|
| name_manually_edited | boolean | | No |
|
||||||
|
| reference_count | integer | | No |
|
||||||
|
| tags | [ string ] | | No |
|
||||||
|
| updated_at | integer | | Yes |
|
||||||
|
| updated_by | string | | No |
|
||||||
|
| updated_by_name | string | | No |
|
||||||
|
| visibility | string | | Yes |
|
||||||
|
|
||||||
|
#### SkillRestorePayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| publish_note | string | | No |
|
||||||
|
| version_id | string | | Yes |
|
||||||
|
| version_name | string | | No |
|
||||||
|
|
||||||
|
#### SkillTagListResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| data | [ [SkillTagResponse](#skilltagresponse) ] | | No |
|
||||||
|
|
||||||
|
#### SkillTagResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| count | integer | | Yes |
|
||||||
|
| tag | string | | Yes |
|
||||||
|
|
||||||
#### SkillToolInferenceResult
|
#### SkillToolInferenceResult
|
||||||
|
|
||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
@@ -21677,6 +22349,60 @@ Validated metadata extracted from a Skill package.
|
|||||||
| inferable | boolean | | Yes |
|
| inferable | boolean | | Yes |
|
||||||
| reason | string | | No |
|
| reason | string | | No |
|
||||||
|
|
||||||
|
#### SkillVersionDeleteResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| deleted | boolean | | Yes |
|
||||||
|
| id | string | | Yes |
|
||||||
|
| latest_published_version_id | string | | No |
|
||||||
|
|
||||||
|
#### SkillVersionDetailResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| archive_size | integer | | Yes |
|
||||||
|
| created_at | integer | | Yes |
|
||||||
|
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
|
||||||
|
| hash_code | string | | Yes |
|
||||||
|
| id | string | | Yes |
|
||||||
|
| is_latest | boolean | | No |
|
||||||
|
| publish_note | string | | Yes |
|
||||||
|
| published_by | string | | No |
|
||||||
|
| published_by_name | string | | No |
|
||||||
|
| skill_id | string | | Yes |
|
||||||
|
| version_name | string | | Yes |
|
||||||
|
| version_number | integer | | Yes |
|
||||||
|
|
||||||
|
#### SkillVersionListResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| data | [ [SkillVersionResponse](#skillversionresponse) ] | | No |
|
||||||
|
|
||||||
|
#### SkillVersionResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| archive_size | integer | | Yes |
|
||||||
|
| created_at | integer | | Yes |
|
||||||
|
| hash_code | string | | Yes |
|
||||||
|
| id | string | | Yes |
|
||||||
|
| is_latest | boolean | | No |
|
||||||
|
| publish_note | string | | Yes |
|
||||||
|
| published_by | string | | No |
|
||||||
|
| published_by_name | string | | No |
|
||||||
|
| skill_id | string | | Yes |
|
||||||
|
| version_name | string | | Yes |
|
||||||
|
| version_number | integer | | Yes |
|
||||||
|
|
||||||
|
#### SkillVersionUpdatePayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| publish_note | string | | No |
|
||||||
|
| version_name | string | | No |
|
||||||
|
|
||||||
#### SnippetDependencyCheckResponse
|
#### SnippetDependencyCheckResponse
|
||||||
|
|
||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
@@ -22144,7 +22870,7 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
|
|||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
| ---- | ---- | ----------- | -------- |
|
| ---- | ---- | ----------- | -------- |
|
||||||
| keyword | string | Search keyword | No |
|
| keyword | string | Search keyword | No |
|
||||||
| type | string, <br>**Available values:** "", "app", "knowledge", "snippet" | Tag type filter<br>*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No |
|
| type | [TagType](#tagtype)<br>string | Tag type filter | No |
|
||||||
|
|
||||||
#### TagListResponse
|
#### TagListResponse
|
||||||
|
|
||||||
@@ -23158,7 +23884,7 @@ How a workflow node is bound to an Agent.
|
|||||||
|
|
||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
| ---- | ---- | ----------- | -------- |
|
| ---- | ---- | ----------- | -------- |
|
||||||
| node_execution_id | string | Workflow node execution ID | Yes |
|
| node_execution_id | string | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No |
|
||||||
| path | string | File path relative to the sandbox workspace | Yes |
|
| path | string | File path relative to the sandbox workspace | Yes |
|
||||||
|
|
||||||
#### WorkflowAppLogPaginationResponse
|
#### WorkflowAppLogPaginationResponse
|
||||||
@@ -24330,6 +25056,15 @@ Workflow tool configuration
|
|||||||
| ---- | ---- | ----------- | -------- |
|
| ---- | ---- | ----------- | -------- |
|
||||||
| permission_keys | [ string ] | | No |
|
| permission_keys | [ string ] | | No |
|
||||||
|
|
||||||
|
#### WorkspaceSkillsQuery
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| keyword | string | Search keyword matching skill name or description. | No |
|
||||||
|
| limit | integer, <br>**Default:** 20 | Number of items per page. | No |
|
||||||
|
| page | integer, <br>**Default:** 1 | Page number. | No |
|
||||||
|
| tag | [ string ] | Skill tag filters. Repeat the parameter for multiple tags. | No |
|
||||||
|
|
||||||
#### WorkspaceTenantResultResponse
|
#### WorkspaceTenantResultResponse
|
||||||
|
|
||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
|
|||||||
@@ -316,10 +316,10 @@ class OracleVector(BaseVector):
|
|||||||
entities.append(current_entity)
|
entities.append(current_entity)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
nltk.data.find("tokenizers/punkt_tab")
|
nltk.data.find("tokenizers/punkt")
|
||||||
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_tab and stopwords")
|
raise LookupError("Unable to find the required NLTK data package: punkt 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
@@ -206,7 +206,7 @@ storage = [
|
|||||||
############################################################
|
############################################################
|
||||||
# [ Tools ] dependency group
|
# [ Tools ] dependency group
|
||||||
############################################################
|
############################################################
|
||||||
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.0,<4.0.0"]
|
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.9.1,<4.0.0"]
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# [ VDB ] workspace plugins — hollow packages under providers/vdb/*
|
# [ VDB ] workspace plugins — hollow packages under providers/vdb/*
|
||||||
|
|||||||
@@ -1599,7 +1599,9 @@ class TenantService:
|
|||||||
return updated_accounts
|
return updated_accounts
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def iter_member_account_id_batches(tenant_id: str, batch_size: int, *, session: Session) -> Iterator[list[str]]:
|
def iter_member_account_id_batches(
|
||||||
|
tenant_id: str, batch_size: int, *, session: Session
|
||||||
|
) -> Iterator[list[str]]:
|
||||||
"""Yield workspace member account ids in bounded, ordered batches."""
|
"""Yield workspace member account ids in bounded, ordered batches."""
|
||||||
offset = 0
|
offset = 0
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -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, Conversation
|
from models import Account
|
||||||
from models.agent import (
|
from models.agent import (
|
||||||
APP_BACKED_AGENT_SOURCES,
|
APP_BACKED_AGENT_SOURCES,
|
||||||
Agent,
|
Agent,
|
||||||
@@ -18,15 +18,12 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -38,7 +35,6 @@ 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,
|
||||||
@@ -46,17 +42,11 @@ 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,
|
||||||
@@ -66,7 +56,6 @@ 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).
|
||||||
@@ -209,18 +198,6 @@ 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:
|
||||||
@@ -255,11 +232,7 @@ class AgentComposerService:
|
|||||||
)
|
)
|
||||||
case ComposerSaveStrategy.SAVE_TO_ROSTER:
|
case ComposerSaveStrategy.SAVE_TO_ROSTER:
|
||||||
binding = cls._save_to_roster(
|
binding = cls._save_to_roster(
|
||||||
session=session,
|
session=session, tenant_id=tenant_id, account_id=account_id, binding=binding, payload=payload
|
||||||
tenant_id=tenant_id,
|
|
||||||
account_id=account_id,
|
|
||||||
binding=binding,
|
|
||||||
payload=payload,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -284,17 +257,6 @@ 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
|
||||||
@@ -428,10 +390,12 @@ 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_normal_agent_draft(
|
draft = cls._get_or_create_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(
|
||||||
@@ -453,34 +417,7 @@ class AgentComposerService:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def save_agent_app_composer(
|
def save_agent_app_composer(
|
||||||
cls,
|
cls, *, session: Session, tenant_id: str, app_id: str, account_id: str, payload: ComposerSavePayload
|
||||||
*,
|
|
||||||
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")
|
||||||
@@ -509,25 +446,11 @@ class AgentComposerService:
|
|||||||
updated_by=account_id,
|
updated_by=account_id,
|
||||||
)
|
)
|
||||||
session.add(agent)
|
session.add(agent)
|
||||||
session.flush()
|
try:
|
||||||
home_snapshot = AgentHomeSnapshotService.create_initial(
|
session.flush()
|
||||||
session=session,
|
except IntegrityError as exc:
|
||||||
tenant_id=tenant_id,
|
session.rollback()
|
||||||
agent_id=agent.id,
|
raise AgentNameConflictError() from exc
|
||||||
)
|
|
||||||
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,
|
||||||
@@ -565,7 +488,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")
|
||||||
draft = cls._save_agent_draft(
|
cls._save_agent_draft(
|
||||||
session=session,
|
session=session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
agent=agent,
|
agent=agent,
|
||||||
@@ -580,7 +503,6 @@ 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()
|
||||||
@@ -601,7 +523,6 @@ 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
|
||||||
@@ -617,9 +538,7 @@ 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 home_snapshot_id == active_version.home_snapshot_id and _agent_soul_config_json(
|
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict)
|
||||||
agent_soul
|
|
||||||
) == _agent_soul_config_json(active_version.config_snapshot_dict)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def publish_agent_app_draft(
|
def publish_agent_app_draft(
|
||||||
@@ -648,11 +567,6 @@ 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,
|
||||||
@@ -662,7 +576,6 @@ 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)
|
||||||
@@ -682,29 +595,6 @@ 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,
|
||||||
@@ -722,23 +612,7 @@ 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), None
|
return cls._serialize_build_draft_state(build_draft)
|
||||||
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,
|
||||||
@@ -750,44 +624,10 @@ 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), retired_binding_id
|
return cls._serialize_build_draft_state(build_draft)
|
||||||
|
|
||||||
@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(
|
||||||
@@ -829,29 +669,6 @@ 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,
|
||||||
@@ -863,21 +680,6 @@ 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,
|
||||||
@@ -888,145 +690,32 @@ 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)
|
||||||
return {"result": "success", "draft": cls._serialize_draft(normal_draft)}, retired_binding_ids
|
session.flush()
|
||||||
|
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 None:
|
if build_draft is not None:
|
||||||
return {"result": "success"}, None
|
session.delete(build_draft)
|
||||||
retired_binding_id: str | None = None
|
session.flush()
|
||||||
if build_draft.agent_workspace_binding_id is not None:
|
return {"result": "success"}
|
||||||
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(
|
||||||
@@ -1590,12 +1279,6 @@ 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,
|
||||||
@@ -1604,7 +1287,6 @@ 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
|
||||||
@@ -1767,11 +1449,6 @@ 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,
|
||||||
@@ -1780,7 +1457,6 @@ 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)
|
||||||
@@ -1914,6 +1590,7 @@ 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)
|
||||||
@@ -1951,7 +1628,6 @@ 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 = (
|
||||||
@@ -1968,7 +1644,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -2008,7 +1683,6 @@ 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
|
||||||
@@ -2075,10 +1749,7 @@ class AgentComposerService:
|
|||||||
agent: Agent,
|
agent: Agent,
|
||||||
created_by: str | None,
|
created_by: str | None,
|
||||||
) -> AgentConfigDraft:
|
) -> AgentConfigDraft:
|
||||||
"""Resolve the normal Draft, rebasing only stale WORKFLOW_ONLY DRAFT rows whose account_id is None.
|
"""Resolve the shared Preview draft, rebasing inline agents when needed."""
|
||||||
|
|
||||||
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,
|
||||||
@@ -2096,8 +1767,6 @@ 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
|
||||||
@@ -2108,7 +1777,6 @@ 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
|
||||||
@@ -2145,9 +1813,7 @@ 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 None:
|
if active_snapshot is not None and cls._rebase_workflow_only_normal_draft(
|
||||||
raise AgentVersionNotFoundError()
|
|
||||||
if cls._rebase_workflow_only_normal_draft(
|
|
||||||
agent=agent,
|
agent=agent,
|
||||||
draft=draft,
|
draft=draft,
|
||||||
snapshot=active_snapshot,
|
snapshot=active_snapshot,
|
||||||
@@ -2161,17 +1827,18 @@ 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 base_snapshot is None:
|
agent_soul = (
|
||||||
raise AgentVersionNotFoundError()
|
AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict)
|
||||||
agent_soul = AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict)
|
if base_snapshot is not None
|
||||||
|
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,
|
base_snapshot_id=base_snapshot.id if base_snapshot else None,
|
||||||
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,
|
||||||
@@ -2473,7 +2140,7 @@ class AgentComposerService:
|
|||||||
|
|
||||||
from services.agent.roster_service import AgentRosterService
|
from services.agent.roster_service import AgentRosterService
|
||||||
|
|
||||||
return AgentRosterService(session).get_or_create_build_conversation(
|
return AgentRosterService(session).get_or_create_agent_app_debug_conversation_id(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
agent_id=agent.id,
|
agent_id=agent.id,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ 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
|
||||||
@@ -225,7 +224,6 @@ 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,
|
||||||
@@ -245,7 +243,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], set[str]]:
|
) -> tuple[dict[str, Any], list[DslImportWarning]]:
|
||||||
"""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))
|
||||||
@@ -258,11 +256,6 @@ 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()
|
||||||
@@ -319,7 +312,7 @@ class AgentDslService:
|
|||||||
|
|
||||||
workflow.graph = json.dumps(graph)
|
workflow.graph = json.dumps(graph)
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
return graph, warnings, retirement_candidates
|
return graph, warnings
|
||||||
|
|
||||||
def clone_inline_binding_for_node(
|
def clone_inline_binding_for_node(
|
||||||
self,
|
self,
|
||||||
@@ -569,17 +562,11 @@ 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)
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ 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."
|
||||||
|
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
"""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",
|
|
||||||
]
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -4,8 +4,10 @@ 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
|
||||||
@@ -23,9 +25,6 @@ from models.agent import (
|
|||||||
AgentScope,
|
AgentScope,
|
||||||
AgentSource,
|
AgentSource,
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
AgentWorkingResourceStatus,
|
|
||||||
AgentWorkspaceBinding,
|
|
||||||
AgentWorkspaceOwnerType,
|
|
||||||
WorkflowAgentBindingType,
|
WorkflowAgentBindingType,
|
||||||
WorkflowAgentNodeBinding,
|
WorkflowAgentNodeBinding,
|
||||||
)
|
)
|
||||||
@@ -37,18 +36,15 @@ 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.collect_agent_resources_task import enqueue_agent_resource_collection
|
from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -308,27 +304,6 @@ 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)
|
||||||
|
|
||||||
@@ -348,19 +323,17 @@ class AgentRosterService:
|
|||||||
updated_by=account_id,
|
updated_by=account_id,
|
||||||
)
|
)
|
||||||
self._session.add(agent)
|
self._session.add(agent)
|
||||||
self._session.flush()
|
try:
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
@@ -381,6 +354,11 @@ 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(
|
||||||
@@ -427,19 +405,17 @@ class AgentRosterService:
|
|||||||
updated_by=account_id,
|
updated_by=account_id,
|
||||||
)
|
)
|
||||||
self._session.add(agent)
|
self._session.add(agent)
|
||||||
self._session.flush()
|
try:
|
||||||
|
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)
|
||||||
@@ -614,15 +590,16 @@ class AgentRosterService:
|
|||||||
self._session.flush()
|
self._session.flush()
|
||||||
return conversation_id
|
return conversation_id
|
||||||
|
|
||||||
def get_or_create_build_conversation(
|
def get_or_create_agent_app_debug_conversation_id(
|
||||||
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 stable Build conversation."""
|
"""Return the current editor's Build or Preview conversation for an Agent App."""
|
||||||
|
|
||||||
agent = self._session.scalar(
|
agent = self._session.scalar(
|
||||||
select(Agent).where(
|
select(Agent).where(
|
||||||
@@ -637,20 +614,21 @@ 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=AgentConfigDraftType.DEBUG_BUILD,
|
draft_type=draft_type,
|
||||||
)
|
)
|
||||||
if commit:
|
if commit:
|
||||||
self._session.commit()
|
self._session.commit()
|
||||||
return conversation_id
|
return conversation_id
|
||||||
|
|
||||||
def get_current_preview_conversation(
|
def load_agent_app_debug_conversation_id(
|
||||||
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 current Preview conversation without creating one."""
|
"""Return the editor's existing scoped conversation without creating or repairing rows."""
|
||||||
|
|
||||||
return self._session.scalar(
|
return self._session.scalar(
|
||||||
select(Conversation.id)
|
select(Conversation.id)
|
||||||
@@ -659,7 +637,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 == AgentConfigDraftType.DRAFT,
|
AgentDebugConversation.draft_type == draft_type,
|
||||||
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,
|
||||||
@@ -679,12 +657,25 @@ class AgentRosterService:
|
|||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
|
||||||
def rotate_preview_conversation(self, *, tenant_id: str, agent_id: str, account_id: str) -> str:
|
def refresh_agent_app_debug_conversation_id(
|
||||||
"""Rotate Preview and retire its exact Conversation-owned Binding.
|
self,
|
||||||
|
*,
|
||||||
|
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.
|
||||||
|
|
||||||
The mapping update and exact CONVERSATION Binding retirement commit in
|
If this account already has a mapping for the requested draft surface, the previous
|
||||||
one transaction. Validation failures fail fast; collection is enqueued
|
conversation is abandoned after the replacement mapping is committed: any ACTIVE
|
||||||
only after commit.
|
conversation-owned Agent runtime sessions for that old conversation are sent through
|
||||||
|
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(
|
||||||
@@ -703,194 +694,142 @@ class AgentRosterService:
|
|||||||
if not backing_app_id:
|
if not backing_app_id:
|
||||||
raise AgentNotFoundError()
|
raise AgentNotFoundError()
|
||||||
|
|
||||||
retired_binding_id: str | None = None
|
conversation_id = self._create_agent_app_debug_conversation(
|
||||||
try:
|
app_id=backing_app_id,
|
||||||
conversation_id = self._create_agent_app_debug_conversation(
|
account_id=account_id,
|
||||||
app_id=backing_app_id,
|
)
|
||||||
account_id=account_id,
|
previous_conversation: tuple[str, str] | None = None
|
||||||
|
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(
|
)
|
||||||
select(AgentDebugConversation).where(
|
if mapping is None:
|
||||||
AgentDebugConversation.tenant_id == tenant_id,
|
self._session.add(
|
||||||
AgentDebugConversation.agent_id == agent_id,
|
AgentDebugConversation(
|
||||||
AgentDebugConversation.account_id == account_id,
|
tenant_id=tenant_id,
|
||||||
AgentDebugConversation.draft_type == AgentConfigDraftType.DRAFT,
|
agent_id=agent_id,
|
||||||
|
app_id=backing_app_id,
|
||||||
|
account_id=account_id,
|
||||||
|
draft_type=draft_type,
|
||||||
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if mapping is None:
|
else:
|
||||||
self._session.add(
|
previous_app_id = mapping.app_id
|
||||||
AgentDebugConversation(
|
previous_conversation_id = mapping.conversation_id
|
||||||
tenant_id=tenant_id,
|
if previous_conversation_id:
|
||||||
agent_id=agent_id,
|
previous_conversation = (previous_app_id or backing_app_id, previous_conversation_id)
|
||||||
app_id=backing_app_id,
|
mapping.app_id = backing_app_id
|
||||||
account_id=account_id,
|
mapping.conversation_id = conversation_id
|
||||||
draft_type=AgentConfigDraftType.DRAFT,
|
self._session.flush()
|
||||||
conversation_id=conversation_id,
|
self._session.commit()
|
||||||
)
|
|
||||||
)
|
if previous_conversation:
|
||||||
else:
|
previous_app_id, previous_conversation_id = previous_conversation
|
||||||
previous_app_id = mapping.app_id or backing_app_id
|
self._cleanup_debug_conversation_runtime_sessions(
|
||||||
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,
|
||||||
binding_ids=(retired_binding_id,),
|
agent_id=agent_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 reset_build_conversation(self, *, tenant_id: str, agent_id: str, account_id: str) -> str:
|
def _cleanup_debug_conversation_runtime_sessions(
|
||||||
"""Reset Build and retire its exact DEBUG_BUILD Draft-owned Binding.
|
self,
|
||||||
|
*,
|
||||||
The mapping update, exact BUILD_DRAFT Binding retirement, and Draft
|
tenant_id: str,
|
||||||
pointer clear commit in one transaction. Validation failures fail fast;
|
agent_id: str,
|
||||||
collection is enqueued only after commit.
|
account_id: str,
|
||||||
"""
|
draft_type: AgentConfigDraftType,
|
||||||
|
app_id: str,
|
||||||
agent = self._session.scalar(
|
conversation_id: str,
|
||||||
select(Agent).where(
|
) -> None:
|
||||||
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:
|
||||||
conversation_id = self._create_agent_app_debug_conversation(
|
session_store = AgentAppRuntimeSessionStore()
|
||||||
app_id=backing_app_id,
|
stored_sessions = session_store.list_active_sessions_for_conversation(
|
||||||
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 == AgentConfigDraftType.DEBUG_BUILD,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
build_draft = self._session.scalar(
|
|
||||||
select(AgentConfigDraft)
|
|
||||||
.where(
|
|
||||||
AgentConfigDraft.tenant_id == tenant_id,
|
|
||||||
AgentConfigDraft.agent_id == agent_id,
|
|
||||||
AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD,
|
|
||||||
AgentConfigDraft.account_id == account_id,
|
|
||||||
)
|
|
||||||
.order_by(AgentConfigDraft.updated_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
if build_draft is not None and build_draft.agent_workspace_binding_id is not None:
|
|
||||||
binding_id = build_draft.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=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
|
|
||||||
|
|
||||||
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,
|
tenant_id=tenant_id,
|
||||||
binding_ids=(retired_binding_id,),
|
app_id=app_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
return conversation_id
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to load Agent App runtime sessions for debug conversation refresh: "
|
||||||
|
"tenant_id=%s app_id=%s conversation_id=%s",
|
||||||
|
tenant_id,
|
||||||
|
app_id,
|
||||||
|
conversation_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
def load_or_create_build_conversation_ids_by_agent_id(
|
for stored_session in stored_sessions:
|
||||||
|
try:
|
||||||
|
if stored_session.runtime_layer_specs:
|
||||||
|
payload = AgentBackendSessionCleanupPayload(
|
||||||
|
session_snapshot=stored_session.session_snapshot,
|
||||||
|
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||||
|
idempotency_key=(
|
||||||
|
f"{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:
|
||||||
|
session_store.mark_cleaned(
|
||||||
|
scope=stored_session.scope,
|
||||||
|
backend_run_id=stored_session.backend_run_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to retire Agent App runtime session for debug conversation refresh: "
|
||||||
|
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||||
|
stored_session.scope.tenant_id,
|
||||||
|
stored_session.scope.app_id,
|
||||||
|
stored_session.scope.conversation_id,
|
||||||
|
stored_session.scope.agent_id,
|
||||||
|
stored_session.backend_run_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_or_create_agent_app_debug_conversation_ids_by_agent_id(
|
||||||
self,
|
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 Build conversations for a page of Agent Apps."""
|
"""Return per-account scoped 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
|
||||||
@@ -900,7 +839,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=AgentConfigDraftType.DEBUG_BUILD,
|
draft_type=draft_type,
|
||||||
)
|
)
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
@@ -1118,6 +1057,7 @@ 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)
|
||||||
@@ -1271,38 +1211,13 @@ 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)
|
||||||
retired_binding_ids: list[str] = []
|
if agent.status == AgentStatus.ARCHIVED:
|
||||||
if agent.status != AgentStatus.ARCHIVED:
|
return
|
||||||
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]:
|
||||||
@@ -1467,11 +1382,9 @@ 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
|
||||||
|
|||||||
@@ -19,12 +19,11 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
|
||||||
import zipfile
|
import zipfile
|
||||||
import zlib
|
import zlib
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||||
|
|
||||||
# Bounds — generous but finite so a hostile upload can't exhaust memory/disk.
|
# Bounds — generous but finite so a hostile upload can't exhaust memory/disk.
|
||||||
_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
|
_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
|
||||||
@@ -33,7 +32,8 @@ _MAX_SKILL_MD_BYTES = 1 * 1024 * 1024
|
|||||||
_MAX_ENTRIES = 5000
|
_MAX_ENTRIES = 5000
|
||||||
_ALLOWED_EXTENSIONS = (".zip", ".skill")
|
_ALLOWED_EXTENSIONS = (".zip", ".skill")
|
||||||
_SKILL_MD_NAME = "SKILL.md"
|
_SKILL_MD_NAME = "SKILL.md"
|
||||||
_HEADING_RE = re.compile(r"^\s*#\s+(.+?)\s*$", re.MULTILINE)
|
_SKILL_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||||
|
_MAX_SKILL_DESCRIPTION_LENGTH = 1024
|
||||||
|
|
||||||
|
|
||||||
class SkillPackageError(Exception):
|
class SkillPackageError(Exception):
|
||||||
@@ -53,13 +53,18 @@ class SkillPackageError(Exception):
|
|||||||
class SkillManifest(BaseModel):
|
class SkillManifest(BaseModel):
|
||||||
"""Validated metadata extracted from a Skill package."""
|
"""Validated metadata extracted from a Skill package."""
|
||||||
|
|
||||||
name: str
|
name: str = Field(min_length=1, max_length=64, pattern=_SKILL_NAME_PATTERN)
|
||||||
description: str
|
description: str = Field(min_length=1, max_length=_MAX_SKILL_DESCRIPTION_LENGTH)
|
||||||
entry_path: str # path of SKILL.md inside the archive
|
entry_path: str # path of SKILL.md inside the archive
|
||||||
files: list[str] # all (safe) file paths inside the archive
|
files: list[str] # all (safe) file paths inside the archive
|
||||||
size: int # total uncompressed bytes
|
size: int # total uncompressed bytes
|
||||||
hash: str # sha256 of the archive bytes
|
hash: str # sha256 of the archive bytes
|
||||||
|
|
||||||
|
@field_validator("name", "description", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _strip_required_string(cls, value: object) -> object:
|
||||||
|
return value.strip() if isinstance(value, str) else value
|
||||||
|
|
||||||
|
|
||||||
class NormalizedSkillPackage(BaseModel):
|
class NormalizedSkillPackage(BaseModel):
|
||||||
"""Canonical skill package bytes and metadata ready to store in agent drive."""
|
"""Canonical skill package bytes and metadata ready to store in agent drive."""
|
||||||
@@ -108,14 +113,17 @@ class SkillPackageService:
|
|||||||
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
|
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
|
||||||
|
|
||||||
name, description = self._parse_skill_md(skill_md)
|
name, description = self._parse_skill_md(skill_md)
|
||||||
manifest = SkillManifest(
|
try:
|
||||||
name=name,
|
manifest = SkillManifest(
|
||||||
description=description,
|
name=name,
|
||||||
entry_path=_SKILL_MD_NAME,
|
description=description,
|
||||||
files=sorted(normalized_members),
|
entry_path=_SKILL_MD_NAME,
|
||||||
size=normalized_size,
|
files=sorted(normalized_members),
|
||||||
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
|
size=normalized_size,
|
||||||
)
|
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise self._manifest_validation_error(exc) from exc
|
||||||
return NormalizedSkillPackage(
|
return NormalizedSkillPackage(
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
archive_bytes=normalized_archive_bytes,
|
archive_bytes=normalized_archive_bytes,
|
||||||
@@ -123,6 +131,31 @@ class SkillPackageService:
|
|||||||
strip_prefix=strip_prefix,
|
strip_prefix=strip_prefix,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _manifest_validation_error(exc: ValidationError) -> SkillPackageError:
|
||||||
|
first_error = exc.errors()[0]
|
||||||
|
loc = first_error["loc"]
|
||||||
|
field = loc[0] if loc else "manifest"
|
||||||
|
error_type = first_error["type"]
|
||||||
|
if field == "name":
|
||||||
|
code = "missing_skill_name" if error_type == "string_too_short" else "invalid_skill_name"
|
||||||
|
message = (
|
||||||
|
"SKILL.md frontmatter name is required"
|
||||||
|
if code == "missing_skill_name"
|
||||||
|
else "SKILL.md frontmatter name must be lowercase letters, numbers, and hyphens only, "
|
||||||
|
"must not start or end with a hyphen, and must be at most 64 characters"
|
||||||
|
)
|
||||||
|
return SkillPackageError(code, message, status_code=400)
|
||||||
|
if field == "description":
|
||||||
|
code = "missing_skill_description" if error_type == "string_too_short" else "invalid_skill_description"
|
||||||
|
message = (
|
||||||
|
"SKILL.md frontmatter description is required"
|
||||||
|
if code == "missing_skill_description"
|
||||||
|
else f"SKILL.md frontmatter description must be at most {_MAX_SKILL_DESCRIPTION_LENGTH} characters"
|
||||||
|
)
|
||||||
|
return SkillPackageError(code, message, status_code=400)
|
||||||
|
return SkillPackageError("invalid_skill_manifest", "SKILL.md frontmatter is invalid", status_code=400)
|
||||||
|
|
||||||
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
|
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
|
||||||
self._check_extension(filename)
|
self._check_extension(filename)
|
||||||
if not content:
|
if not content:
|
||||||
@@ -280,13 +313,6 @@ class SkillPackageService:
|
|||||||
frontmatter = cls._parse_frontmatter(content)
|
frontmatter = cls._parse_frontmatter(content)
|
||||||
name = str(frontmatter.get("name") or "").strip()
|
name = str(frontmatter.get("name") or "").strip()
|
||||||
description = str(frontmatter.get("description") or "").strip()
|
description = str(frontmatter.get("description") or "").strip()
|
||||||
if not name:
|
|
||||||
heading = _HEADING_RE.search(content)
|
|
||||||
name = heading.group(1).strip() if heading else ""
|
|
||||||
if not name:
|
|
||||||
raise SkillPackageError(
|
|
||||||
"missing_skill_name", "SKILL.md must declare a name (frontmatter or top heading)", status_code=400
|
|
||||||
)
|
|
||||||
return name, description
|
return name, description
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ 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 (
|
||||||
@@ -225,7 +224,7 @@ class WorkflowAgentPublishService:
|
|||||||
session: Session,
|
session: Session,
|
||||||
draft_workflow: Workflow,
|
draft_workflow: Workflow,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
) -> set[str]:
|
) -> None:
|
||||||
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(
|
||||||
@@ -238,12 +237,9 @@ 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():
|
||||||
@@ -256,34 +252,16 @@ 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_binding,
|
existing_binding=existing_by_node_id.get(node_id),
|
||||||
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(
|
||||||
@@ -292,8 +270,8 @@ class WorkflowAgentPublishService:
|
|||||||
session: Session,
|
session: Session,
|
||||||
draft_workflow: Workflow,
|
draft_workflow: Workflow,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
) -> set[str]:
|
) -> None:
|
||||||
return cls.sync_agent_bindings_for_draft(
|
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,
|
||||||
@@ -583,32 +561,12 @@ class WorkflowAgentPublishService:
|
|||||||
session: Session,
|
session: Session,
|
||||||
draft_workflow: Workflow,
|
draft_workflow: Workflow,
|
||||||
published_workflow: Workflow,
|
published_workflow: Workflow,
|
||||||
) -> set[str]:
|
) -> None:
|
||||||
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 retirement_candidates
|
return
|
||||||
|
|
||||||
bindings = session.scalars(
|
bindings = session.scalars(
|
||||||
select(WorkflowAgentNodeBinding).where(
|
select(WorkflowAgentNodeBinding).where(
|
||||||
@@ -620,7 +578,7 @@ class WorkflowAgentPublishService:
|
|||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
if not bindings:
|
if not bindings:
|
||||||
return retirement_candidates
|
return
|
||||||
|
|
||||||
agents_by_id = {
|
agents_by_id = {
|
||||||
agent.id: agent
|
agent.id: agent
|
||||||
@@ -653,7 +611,6 @@ 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(
|
||||||
@@ -663,7 +620,7 @@ class WorkflowAgentPublishService:
|
|||||||
source_workflow: Workflow,
|
source_workflow: Workflow,
|
||||||
draft_workflow: Workflow,
|
draft_workflow: Workflow,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
) -> set[str]:
|
) -> None:
|
||||||
"""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(
|
||||||
@@ -674,11 +631,6 @@ 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)
|
||||||
|
|
||||||
@@ -729,4 +681,3 @@ class WorkflowAgentPublishService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.flush()
|
session.flush()
|
||||||
return retirement_candidates
|
|
||||||
|
|||||||
@@ -1,469 +0,0 @@
|
|||||||
"""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",
|
|
||||||
]
|
|
||||||
@@ -1,40 +1,39 @@
|
|||||||
"""Resolve product locators to ACTIVE Workspace Bindings and proxy file access."""
|
"""Resolve and proxy sandbox file access for Agent App and workflow Agent sessions.
|
||||||
|
|
||||||
|
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, Literal, cast
|
from typing import Any
|
||||||
|
|
||||||
|
from agenton.compositor import CompositorSessionSnapshot
|
||||||
from dify_agent.client import Client
|
from dify_agent.client import Client
|
||||||
from dify_agent.layers.execution_context import (
|
from dify_agent.protocol import RuntimeLayerSpec, SandboxLocator, build_sandbox_locator_from_layer_specs
|
||||||
DifyExecutionContextAgentConfigVersionKind,
|
from pydantic import BaseModel, TypeAdapter
|
||||||
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 (
|
from models.agent import AgentRuntimeSessionOwnerType, WorkflowAgentRuntimeSession, WorkflowAgentRuntimeSessionStatus
|
||||||
Agent,
|
|
||||||
AgentConfigDraft,
|
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
|
||||||
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
|
||||||
@@ -47,200 +46,82 @@ 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:
|
||||||
def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
|
"""Inspect and proxy file access for an Agent App conversation sandbox."""
|
||||||
|
|
||||||
|
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(
|
def get_info(self, *, tenant_id: str, app_id: str, conversation_id: str) -> AgentSandboxInfo:
|
||||||
self,
|
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
|
||||||
*,
|
session_id, workspace_cwd = _extract_shell_workspace_or_raise(
|
||||||
tenant_id: str,
|
snapshot=locator.session_snapshot,
|
||||||
app_id: str,
|
not_found_message="this conversation's agent has no sandbox workspace",
|
||||||
agent_id: str,
|
|
||||||
caller_type: Literal["conversation", "build_draft"],
|
|
||||||
caller_id: str,
|
|
||||||
account_id: str,
|
|
||||||
) -> AgentSandboxInfo:
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
return AgentSandboxInfo(workspace_cwd=".")
|
|
||||||
|
|
||||||
def list_files(
|
return AgentSandboxInfo(
|
||||||
self,
|
session_id=session_id,
|
||||||
*,
|
workspace_cwd=workspace_cwd,
|
||||||
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(
|
def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
|
||||||
self,
|
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)
|
||||||
tenant_id: str,
|
|
||||||
app_id: str,
|
def read_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
|
||||||
agent_id: str,
|
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
|
||||||
caller_type: Literal["conversation", "build_draft"],
|
return self._client_factory().read_sandbox_file_sync(locator, path)
|
||||||
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(
|
def upload_file(
|
||||||
self,
|
self, *, tenant_id: str, app_id: str, conversation_id: str, path: str
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
app_id: str,
|
|
||||||
agent_id: str,
|
|
||||||
caller_type: Literal["conversation", "build_draft"],
|
|
||||||
caller_id: str,
|
|
||||||
account_id: str,
|
|
||||||
path: str,
|
|
||||||
) -> AgentSandboxUploadDownload:
|
) -> AgentSandboxUploadDownload:
|
||||||
binding = self._resolve_binding(
|
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,
|
||||||
agent_id=agent_id,
|
conversation_id=conversation_id,
|
||||||
caller_type=caller_type,
|
|
||||||
caller_id=caller_id,
|
|
||||||
account_id=account_id,
|
|
||||||
)
|
)
|
||||||
with self._client_factory() as client:
|
if stored is None:
|
||||||
uploaded = client.upload_workspace_file_sync(
|
raise AgentSandboxInspectorError(
|
||||||
WorkspaceUploadRequest(
|
"no_active_session",
|
||||||
backend_binding_ref=binding.backend_binding_ref,
|
"this conversation has no active sandbox session yet",
|
||||||
path=path,
|
status_code=404,
|
||||||
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 _upload_download_response(tenant_id=tenant_id, file_mapping=uploaded.file.model_dump(mode="python"))
|
return _build_locator_or_raise(
|
||||||
|
snapshot=stored.session_snapshot,
|
||||||
@staticmethod
|
runtime_layer_specs=stored.runtime_layer_specs,
|
||||||
def _resolve_binding(
|
not_found_message="this conversation's agent has no sandbox workspace",
|
||||||
*,
|
)
|
||||||
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
|
||||||
|
|
||||||
@@ -251,11 +132,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,
|
node_execution_id: str | None,
|
||||||
path: str,
|
path: str,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> WorkspaceListResponse:
|
):
|
||||||
binding = self._resolve_binding(
|
locator = self._resolve_locator(
|
||||||
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,
|
||||||
@@ -263,8 +144,7 @@ class WorkflowAgentSandboxService:
|
|||||||
node_execution_id=node_execution_id,
|
node_execution_id=node_execution_id,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
with self._client_factory() as client:
|
return self._client_factory().list_sandbox_files_sync(locator, path)
|
||||||
return client.list_workspace_files_sync(binding.backend_binding_ref, path)
|
|
||||||
|
|
||||||
def read_file(
|
def read_file(
|
||||||
self,
|
self,
|
||||||
@@ -273,11 +153,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,
|
node_execution_id: str | None,
|
||||||
path: str,
|
path: str,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> WorkspaceReadResponse:
|
):
|
||||||
binding = self._resolve_binding(
|
locator = self._resolve_locator(
|
||||||
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,
|
||||||
@@ -285,8 +165,7 @@ class WorkflowAgentSandboxService:
|
|||||||
node_execution_id=node_execution_id,
|
node_execution_id=node_execution_id,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
with self._client_factory() as client:
|
return self._client_factory().read_sandbox_file_sync(locator, path)
|
||||||
return client.read_workspace_file_sync(binding.backend_binding_ref, path)
|
|
||||||
|
|
||||||
def upload_file(
|
def upload_file(
|
||||||
self,
|
self,
|
||||||
@@ -295,11 +174,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,
|
node_execution_id: str | None,
|
||||||
path: str,
|
path: str,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> AgentSandboxUploadDownload:
|
) -> AgentSandboxUploadDownload:
|
||||||
binding = self._resolve_binding(
|
locator = self._resolve_locator(
|
||||||
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,
|
||||||
@@ -307,97 +186,118 @@ class WorkflowAgentSandboxService:
|
|||||||
node_execution_id=node_execution_id,
|
node_execution_id=node_execution_id,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
with self._client_factory() as client:
|
uploaded = self._client_factory().upload_sandbox_file_sync(locator, path)
|
||||||
uploaded = client.upload_workspace_file_sync(
|
return _upload_download_response(
|
||||||
WorkspaceUploadRequest(
|
tenant_id=tenant_id,
|
||||||
backend_binding_ref=binding.backend_binding_ref,
|
file_mapping=uploaded.file.model_dump(mode="python"),
|
||||||
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"))
|
|
||||||
|
|
||||||
@staticmethod
|
def _resolve_locator(
|
||||||
def _resolve_binding(
|
self,
|
||||||
*,
|
*,
|
||||||
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,
|
node_execution_id: str | None,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> AgentWorkspaceBinding:
|
) -> SandboxLocator:
|
||||||
execution = session.scalar(
|
"""Resolve one workflow Agent sandbox from product-facing identifiers.
|
||||||
select(WorkflowNodeExecutionModel).where(
|
|
||||||
WorkflowNodeExecutionModel.id == node_execution_id,
|
Callers may target either a specific node execution or the current node
|
||||||
WorkflowNodeExecutionModel.tenant_id == tenant_id,
|
as a whole. When ``node_execution_id`` is provided, lookup narrows to
|
||||||
WorkflowNodeExecutionModel.app_id == app_id,
|
that execution's ACTIVE runtime-session row. When it is omitted, the
|
||||||
WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id,
|
service falls back to the most recently updated ACTIVE session for the
|
||||||
WorkflowNodeExecutionModel.node_id == node_id,
|
same ``workflow_run_id + node_id`` pair so console sandbox inspection can
|
||||||
)
|
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,
|
||||||
)
|
)
|
||||||
process_data = execution.process_data_dict if execution is not None else None
|
if node_execution_id:
|
||||||
workflow_agent_binding_id = process_data.get("workflow_agent_binding_id") if process_data is not None else None
|
stmt = stmt.where(WorkflowAgentRuntimeSession.node_execution_id == node_execution_id)
|
||||||
if (
|
stmt = stmt.order_by(WorkflowAgentRuntimeSession.updated_at.desc()).limit(1)
|
||||||
execution is None
|
|
||||||
or execution.agent_workspace_binding_id is None
|
row = session.scalar(stmt)
|
||||||
or not isinstance(workflow_agent_binding_id, str)
|
|
||||||
):
|
if row is None:
|
||||||
raise AgentSandboxInspectorError(
|
raise AgentSandboxInspectorError(
|
||||||
"no_active_binding",
|
"no_active_session",
|
||||||
"this Workflow Agent node execution has no active Workspace Binding",
|
"this workflow Agent node has no active sandbox session yet",
|
||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
binding = AgentWorkspaceService.get_active_binding(
|
return _build_locator_or_raise(
|
||||||
session=session,
|
snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
|
||||||
tenant_id=tenant_id,
|
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
|
||||||
binding_id=execution.agent_workspace_binding_id,
|
not_found_message="this workflow Agent node has no sandbox workspace",
|
||||||
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(
|
|
||||||
"no_active_binding",
|
def _build_locator_or_raise(
|
||||||
"this Workflow Agent node execution has no active Workspace Binding",
|
*,
|
||||||
status_code=404,
|
snapshot: CompositorSessionSnapshot,
|
||||||
)
|
runtime_layer_specs: list[RuntimeLayerSpec],
|
||||||
return binding
|
not_found_message: str,
|
||||||
|
) -> 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(mapping=file_mapping, tenant_id=tenant_id, access_controller=controller)
|
file = file_factory.build_from_mapping(
|
||||||
|
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(
|
||||||
"workspace_upload_download_unavailable",
|
"sandbox_upload_download_unavailable",
|
||||||
"uploaded Workspace file could not be converted to a download URL",
|
"uploaded sandbox 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(
|
||||||
"workspace_upload_download_unavailable",
|
"sandbox_upload_download_unavailable",
|
||||||
"uploaded Workspace file does not support download URL generation",
|
"uploaded sandbox 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))
|
||||||
@@ -415,7 +315,7 @@ def _default_client_factory() -> Client:
|
|||||||
if not base_url:
|
if not base_url:
|
||||||
raise AgentSandboxInspectorError(
|
raise AgentSandboxInspectorError(
|
||||||
"inspector_unavailable",
|
"inspector_unavailable",
|
||||||
"the Workspace file inspector is not available (Agent backend not configured)",
|
"the sandbox 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)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from models.tools import ToolFile
|
|||||||
from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService
|
from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService
|
||||||
from services.agent.skill_package_service import SkillPackageError
|
from services.agent.skill_package_service import SkillPackageError
|
||||||
from services.agent_drive_service import DriveFileRef
|
from services.agent_drive_service import DriveFileRef
|
||||||
|
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
|
||||||
|
|
||||||
|
|
||||||
class AgentConfigVersionKind(StrEnum):
|
class AgentConfigVersionKind(StrEnum):
|
||||||
@@ -98,6 +99,7 @@ class ConfigPushPayload(BaseModel):
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AgentConfigTarget:
|
class AgentConfigTarget:
|
||||||
|
tenant_id: str
|
||||||
agent_id: str
|
agent_id: str
|
||||||
version_id: str
|
version_id: str
|
||||||
kind: AgentConfigVersionKind
|
kind: AgentConfigVersionKind
|
||||||
@@ -146,6 +148,7 @@ class AgentConfigService:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
return AgentConfigTarget(
|
return AgentConfigTarget(
|
||||||
|
tenant_id=tenant_id,
|
||||||
agent_id=target.agent_id,
|
agent_id=target.agent_id,
|
||||||
version_id=target.version_id,
|
version_id=target.version_id,
|
||||||
kind=target.kind,
|
kind=target.kind,
|
||||||
@@ -191,7 +194,7 @@ class AgentConfigService:
|
|||||||
return {
|
return {
|
||||||
"agent_id": target.agent_id,
|
"agent_id": target.agent_id,
|
||||||
"config_version": self._config_version_payload(target),
|
"config_version": self._config_version_payload(target),
|
||||||
"items": [self._serialize_skill_item(skill) for skill in target.agent_soul.config_skills],
|
"items": self._skill_items_for_target(target),
|
||||||
}
|
}
|
||||||
|
|
||||||
def list_files(
|
def list_files(
|
||||||
@@ -233,10 +236,27 @@ class AgentConfigService:
|
|||||||
config_version_kind=config_version_kind,
|
config_version_kind=config_version_kind,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
skill = self._require_skill(target.agent_soul, name=name)
|
try:
|
||||||
file_id = self._available_skill_file_id(skill)
|
skill = self._require_skill(target.agent_soul, name=name)
|
||||||
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
file_id = self._available_skill_file_id(skill)
|
||||||
return ConfigDownload(filename=f"{skill.name}.zip", mime_type=mime_type or "application/zip", payload=payload)
|
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
||||||
|
return ConfigDownload(
|
||||||
|
filename=f"{skill.name}.zip",
|
||||||
|
mime_type=mime_type or "application/zip",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
except AgentConfigServiceError as exc:
|
||||||
|
if exc.code != "config_skill_not_found":
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
result = SkillManagementService().pull_runtime_agent_skill(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
return ConfigDownload(filename=result.filename, mime_type=result.mime_type, payload=result.payload)
|
||||||
|
except SkillManagementServiceError as exc:
|
||||||
|
raise AgentConfigServiceError("config_skill_not_found", "config skill not found", status_code=404) from exc
|
||||||
|
|
||||||
def download_skill_url(
|
def download_skill_url(
|
||||||
self,
|
self,
|
||||||
@@ -279,9 +299,45 @@ class AgentConfigService:
|
|||||||
config_version_kind=config_version_kind,
|
config_version_kind=config_version_kind,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
skill = self._require_skill(target.agent_soul, name=name)
|
try:
|
||||||
file_id = self._available_skill_file_id(skill)
|
skill = self._require_skill(target.agent_soul, name=name)
|
||||||
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
file_id = self._available_skill_file_id(skill)
|
||||||
|
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
||||||
|
skill_item = self._serialize_skill_item(skill)
|
||||||
|
except AgentConfigServiceError as exc:
|
||||||
|
if exc.code != "config_skill_not_found":
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
workspace_archive = SkillManagementService().pull_runtime_agent_skill(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
except SkillManagementServiceError as skill_exc:
|
||||||
|
raise AgentConfigServiceError(
|
||||||
|
"config_skill_not_found",
|
||||||
|
"config skill not found",
|
||||||
|
status_code=404,
|
||||||
|
) from skill_exc
|
||||||
|
archive_bytes = workspace_archive.payload
|
||||||
|
skill_item = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in SkillManagementService().list_runtime_agent_skills(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
)
|
||||||
|
if item["name"] == name
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": name,
|
||||||
|
"name": name,
|
||||||
|
"description": "",
|
||||||
|
"size": None,
|
||||||
|
"hash": None,
|
||||||
|
"mime_type": "application/zip",
|
||||||
|
},
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
archive_items, skill_md = self._inspect_skill_archive(archive_bytes)
|
archive_items, skill_md = self._inspect_skill_archive(archive_bytes)
|
||||||
except (OSError, ValueError, zipfile.BadZipFile) as exc:
|
except (OSError, ValueError, zipfile.BadZipFile) as exc:
|
||||||
@@ -291,7 +347,7 @@ class AgentConfigService:
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
) from exc
|
) from exc
|
||||||
return {
|
return {
|
||||||
**self._serialize_skill_item(skill),
|
**skill_item,
|
||||||
"source": "config_skill_zip",
|
"source": "config_skill_zip",
|
||||||
"files": archive_items,
|
"files": archive_items,
|
||||||
"skill_md": skill_md,
|
"skill_md": skill_md,
|
||||||
@@ -839,6 +895,7 @@ class AgentConfigService:
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
return AgentConfigTarget(
|
return AgentConfigTarget(
|
||||||
|
tenant_id=tenant_id,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
version_id=version.id,
|
version_id=version.id,
|
||||||
kind=config_version_kind,
|
kind=config_version_kind,
|
||||||
@@ -1133,9 +1190,7 @@ class AgentConfigService:
|
|||||||
return {
|
return {
|
||||||
"agent_id": target.agent_id,
|
"agent_id": target.agent_id,
|
||||||
"config_version": AgentConfigService._config_version_payload(target),
|
"config_version": AgentConfigService._config_version_payload(target),
|
||||||
"skills": {
|
"skills": {"items": AgentConfigService._skill_items_for_target(target)},
|
||||||
"items": [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
|
|
||||||
},
|
|
||||||
"files": {
|
"files": {
|
||||||
"items": [
|
"items": [
|
||||||
AgentConfigService._serialize_file_item(file_ref) for file_ref in target.agent_soul.config_files
|
AgentConfigService._serialize_file_item(file_ref) for file_ref in target.agent_soul.config_files
|
||||||
@@ -1145,6 +1200,20 @@ class AgentConfigService:
|
|||||||
"note": target.agent_soul.config_note,
|
"note": target.agent_soul.config_note,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _skill_items_for_target(target: AgentConfigTarget) -> list[dict[str, object]]:
|
||||||
|
items = [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
|
||||||
|
seen_names = {str(item["name"]) for item in items}
|
||||||
|
for item in SkillManagementService().list_runtime_agent_skills(
|
||||||
|
tenant_id=target.tenant_id,
|
||||||
|
agent_id=target.agent_id,
|
||||||
|
):
|
||||||
|
if item["name"] in seen_names:
|
||||||
|
continue
|
||||||
|
seen_names.add(str(item["name"]))
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _config_version_payload(target: AgentConfigTarget) -> dict[str, object]:
|
def _config_version_payload(target: AgentConfigTarget) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ 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
|
||||||
@@ -52,7 +51,6 @@ 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__)
|
||||||
|
|
||||||
@@ -548,7 +546,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, retirement_candidates = AgentDslService(self._session).import_workflow_packages(
|
_, warnings = 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,
|
||||||
@@ -559,17 +557,6 @@ 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")
|
||||||
|
|||||||
@@ -26,29 +26,17 @@ 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 (
|
from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentIconType, AgentScope, AgentStatus
|
||||||
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__)
|
||||||
@@ -126,7 +114,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) # guard-ignore: no-new-getattr -- delegates model fields
|
return getattr(self._app_model_config, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def annotation_reply_dict(self) -> Any:
|
def annotation_reply_dict(self) -> Any:
|
||||||
@@ -141,7 +129,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) # guard-ignore: no-new-getattr -- delegates model fields
|
return getattr(self._app, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def desc_or_prompt(self) -> str:
|
def desc_or_prompt(self) -> str:
|
||||||
@@ -490,14 +478,7 @@ class AppService:
|
|||||||
|
|
||||||
session.delete(existing_star)
|
session.delete(existing_star)
|
||||||
|
|
||||||
def create_app(
|
def create_app(self, tenant_id: str, params: CreateAppParams, account: Account, *, session: Session) -> 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
|
||||||
@@ -962,67 +943,18 @@ 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)
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ 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
|
||||||
@@ -14,9 +16,7 @@ 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.collect_agent_resources_task import enqueue_agent_resource_collection
|
from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session
|
||||||
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,30 +189,23 @@ 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.
|
||||||
|
|
||||||
Conversation deletion is the product lifecycle boundary for its
|
Before removing the conversation row, this best-effort lifecycle path
|
||||||
Workspace. Physical collection happens only after the retire commit.
|
enumerates any ACTIVE conversation-owned Agent backend runtime sessions,
|
||||||
|
enqueues asynchronous backend cleanup for rows with persisted runtime
|
||||||
|
layer specs, and then retires the local session rows even if enqueueing
|
||||||
|
fails. Conversation deletion and related-data cleanup scheduling still
|
||||||
|
proceed when that lifecycle bookkeeping only partially succeeds.
|
||||||
|
|
||||||
Raises:
|
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)
|
||||||
binding_id = conversation.agent_workspace_binding_id
|
session_store = AgentAppRuntimeSessionStore()
|
||||||
retired_binding_id: str | None = None
|
stored_sessions = session_store.list_active_sessions_for_conversation(
|
||||||
if binding_id is not None:
|
tenant_id=app_model.tenant_id,
|
||||||
owner_scope = WorkspaceOwnerScope(
|
app_id=app_model.id,
|
||||||
tenant_id=app_model.tenant_id,
|
conversation_id=conversation.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(
|
||||||
@@ -220,25 +213,66 @@ class ConversationService:
|
|||||||
app_model.name,
|
app_model.name,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
)
|
)
|
||||||
if binding_id is not None:
|
for stored_session in stored_sessions:
|
||||||
retired_binding_id = AgentWorkspaceService.retire_binding(
|
try:
|
||||||
session=session,
|
if stored_session.runtime_layer_specs:
|
||||||
tenant_id=app_model.tenant_id,
|
payload = AgentBackendSessionCleanupPayload(
|
||||||
binding_id=binding_id,
|
session_snapshot=stored_session.session_snapshot,
|
||||||
)
|
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||||
if retired_binding_id is None:
|
idempotency_key=(
|
||||||
raise AgentWorkspaceNotFoundError("Conversation participant Binding is unavailable")
|
f"{stored_session.scope.tenant_id}:{stored_session.scope.app_id}:"
|
||||||
|
f"{stored_session.scope.conversation_id}:agent-runtime-session-cleanup:"
|
||||||
|
f"{stored_session.scope.agent_id}:"
|
||||||
|
f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:"
|
||||||
|
f"{stored_session.backend_run_id or 'no-run'}"
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"tenant_id": stored_session.scope.tenant_id,
|
||||||
|
"app_id": stored_session.scope.app_id,
|
||||||
|
"conversation_id": stored_session.scope.conversation_id,
|
||||||
|
"agent_id": stored_session.scope.agent_id,
|
||||||
|
"agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id,
|
||||||
|
"previous_agent_backend_run_id": stored_session.backend_run_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cleanup_conversation_agent_runtime_session.delay(payload.model_dump(mode="json"))
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to enqueue Agent backend cleanup for conversation deletion: "
|
||||||
|
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||||
|
stored_session.scope.tenant_id,
|
||||||
|
stored_session.scope.app_id,
|
||||||
|
stored_session.scope.conversation_id,
|
||||||
|
stored_session.scope.agent_id,
|
||||||
|
stored_session.backend_run_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
session_store.mark_cleaned(
|
||||||
|
scope=stored_session.scope,
|
||||||
|
backend_run_id=stored_session.backend_run_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to retire Agent App runtime session for conversation deletion: "
|
||||||
|
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s",
|
||||||
|
stored_session.scope.tenant_id,
|
||||||
|
stored_session.scope.app_id,
|
||||||
|
stored_session.scope.conversation_id,
|
||||||
|
stored_session.scope.agent_id,
|
||||||
|
stored_session.backend_run_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
session.delete(conversation)
|
session.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(
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ 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 (
|
||||||
@@ -48,7 +47,6 @@ 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)
|
||||||
@@ -713,7 +711,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, retirement_candidates = workflow_service.publish_workflow(
|
workflow = 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,
|
||||||
@@ -723,16 +721,6 @@ 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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,14 +19,12 @@ 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__)
|
||||||
|
|
||||||
@@ -428,7 +426,6 @@ 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 {}
|
||||||
@@ -447,10 +444,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=False,
|
sync_agent_bindings=not raw_agent_packages,
|
||||||
)
|
)
|
||||||
if raw_agent_packages:
|
if raw_agent_packages:
|
||||||
_, warnings, retirement_candidates = AgentDslService(self._session).import_workflow_packages(
|
_, warnings = 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,
|
||||||
@@ -461,29 +458,8 @@ 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:
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ 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 (
|
||||||
@@ -43,7 +42,6 @@ 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__)
|
||||||
|
|
||||||
@@ -82,7 +80,7 @@ class SnippetService:
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _session_scope(self) -> Generator[Session, None, None]:
|
def _session_scope(self) -> Generator[Session, None, None]:
|
||||||
current_session = self._session
|
current_session = getattr(self, "_session", None)
|
||||||
if current_session is not None:
|
if current_session is not None:
|
||||||
yield current_session
|
yield current_session
|
||||||
return
|
return
|
||||||
@@ -91,7 +89,7 @@ class SnippetService:
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
def _commit_if_owned(self, session: Session) -> None:
|
def _commit_if_owned(self, session: Session) -> None:
|
||||||
if self._session is None:
|
if getattr(self, "_session", None) is None:
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -602,13 +600,12 @@ 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()
|
||||||
retirement_candidates = WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||||
session=session,
|
session=session,
|
||||||
draft_workflow=workflow,
|
draft_workflow=workflow,
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
@@ -618,17 +615,6 @@ 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(
|
||||||
@@ -670,24 +656,13 @@ class SnippetService:
|
|||||||
session.flush()
|
session.flush()
|
||||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||||
|
|
||||||
retirement_candidates = WorkflowAgentPublishService.restore_agent_node_bindings_to_draft(
|
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(
|
||||||
@@ -696,7 +671,7 @@ class SnippetService:
|
|||||||
session: Session,
|
session: Session,
|
||||||
snippet: CustomizedSnippet,
|
snippet: CustomizedSnippet,
|
||||||
account: Account,
|
account: Account,
|
||||||
) -> tuple[Workflow, set[str]]:
|
) -> Workflow:
|
||||||
"""
|
"""
|
||||||
Publish the draft workflow as a new version.
|
Publish the draft workflow as a new version.
|
||||||
|
|
||||||
@@ -740,7 +715,7 @@ class SnippetService:
|
|||||||
kind=WorkflowKind.SNIPPET.value,
|
kind=WorkflowKind.SNIPPET.value,
|
||||||
)
|
)
|
||||||
session.add(workflow)
|
session.add(workflow)
|
||||||
retirement_candidates = WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
|
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,
|
||||||
@@ -753,7 +728,7 @@ class SnippetService:
|
|||||||
snippet.updated_by = account.id
|
snippet.updated_by = account.id
|
||||||
session.add(snippet)
|
session.add(snippet)
|
||||||
|
|
||||||
return workflow, retirement_candidates
|
return workflow
|
||||||
|
|
||||||
def get_all_published_workflows(
|
def get_all_published_workflows(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from werkzeug.exceptions import NotFound
|
|||||||
from models.dataset import Dataset
|
from models.dataset import Dataset
|
||||||
from models.enums import TagType
|
from models.enums import TagType
|
||||||
from models.model import App, Tag, TagBinding
|
from models.model import App, Tag, TagBinding
|
||||||
|
from models.skill import Skill
|
||||||
from models.snippet import CustomizedSnippet
|
from models.snippet import CustomizedSnippet
|
||||||
|
|
||||||
type _TagTypeLike = TagType | str
|
type _TagTypeLike = TagType | str
|
||||||
@@ -282,5 +283,13 @@ class TagService:
|
|||||||
)
|
)
|
||||||
if not snippet:
|
if not snippet:
|
||||||
raise NotFound("Snippet not found")
|
raise NotFound("Snippet not found")
|
||||||
|
elif type == "skill":
|
||||||
|
skill = session.scalar(
|
||||||
|
select(Skill)
|
||||||
|
.where(Skill.tenant_id == current_user.current_tenant_id, Skill.id == target_id)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if not skill:
|
||||||
|
raise NotFound("Skill not found")
|
||||||
else:
|
else:
|
||||||
raise NotFound("Invalid binding type")
|
raise NotFound("Invalid binding type")
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ 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,
|
||||||
@@ -84,7 +83,6 @@ 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)
|
||||||
@@ -376,9 +374,8 @@ 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:
|
||||||
retirement_candidates = WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||||
session=session,
|
session=session,
|
||||||
draft_workflow=workflow,
|
draft_workflow=workflow,
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
@@ -391,16 +388,6 @@ 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:
|
||||||
@@ -522,7 +509,7 @@ class WorkflowService:
|
|||||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||||
|
|
||||||
session.flush()
|
session.flush()
|
||||||
retirement_candidates = WorkflowAgentPublishService.restore_agent_node_bindings_to_draft(
|
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,
|
||||||
@@ -530,16 +517,6 @@ 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
|
||||||
@@ -552,7 +529,7 @@ class WorkflowService:
|
|||||||
account: Account,
|
account: Account,
|
||||||
marked_name: str = "",
|
marked_name: str = "",
|
||||||
marked_comment: str = "",
|
marked_comment: str = "",
|
||||||
) -> tuple[Workflow, set[str]]:
|
) -> Workflow:
|
||||||
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,
|
||||||
@@ -611,7 +588,7 @@ class WorkflowService:
|
|||||||
|
|
||||||
# commit db session changes
|
# commit db session changes
|
||||||
session.add(workflow)
|
session.add(workflow)
|
||||||
retirement_candidates = WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
|
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,
|
||||||
@@ -621,7 +598,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, retirement_candidates
|
return workflow
|
||||||
|
|
||||||
def _validate_workflow_credentials(self, workflow: Workflow, *, session: Session) -> None:
|
def _validate_workflow_credentials(self, workflow: Workflow, *, session: Session) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""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,7 +51,6 @@ 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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
"""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,17 +5,25 @@ 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,
|
||||||
@@ -50,8 +58,13 @@ 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)
|
||||||
@@ -59,6 +72,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
|
|||||||
logger.info(click.style(f"Start deleting app and related data: {tenant_id}:{app_id}", fg="green"))
|
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)
|
||||||
@@ -99,6 +113,143 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
|
|||||||
raise self.retry(exc=e, countdown=60) # Retry after 60 seconds
|
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(
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -145,9 +144,8 @@ 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(process_data)) if process_data is not None else "{}"
|
json.dumps(json_converter.to_json_encodable(execution.process_data)) if execution.process_data 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,94 +2,114 @@ extend = "../../.ruff.toml"
|
|||||||
src = ["../.."]
|
src = ["../.."]
|
||||||
|
|
||||||
[lint]
|
[lint]
|
||||||
extend-select = ["ANN401", "ARG"]
|
extend-select = ["ANN401", "ARG", "TID251"]
|
||||||
|
|
||||||
# Existing strict-mode debt. Remove a file entry when bringing it under strict checking.
|
|
||||||
[lint.per-file-ignores]
|
[lint.per-file-ignores]
|
||||||
"controllers/console/test_apikey.py" = ["ARG002"]
|
"core/rag/pipeline/test_queue_integration.py" = ["ANN401", "TID251", "ARG"]
|
||||||
"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"]
|
||||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG002", "ARG005"]
|
"services/test_app_dsl_service.py" = ["ANN401", "TID251", "ARG"]
|
||||||
"repositories/test_workflow_run_repository.py" = ["ARG002"]
|
"services/test_file_service_zip_and_lookup.py" = ["ANN401", "TID251", "ARG"]
|
||||||
"services/auth/test_auth_integration.py" = ["ARG002"]
|
|
||||||
"services/dataset_collection_binding.py" = ["ARG002"]
|
|
||||||
"services/document_service_status.py" = ["ARG002"]
|
|
||||||
"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG002"]
|
|
||||||
"services/recommend_app/test_database_retrieval.py" = ["ARG002"]
|
|
||||||
"services/test_account_service.py" = ["ARG002"]
|
|
||||||
"services/test_advanced_prompt_template_service.py" = ["ARG002"]
|
|
||||||
"services/test_app_dsl_service.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"]
|
|
||||||
"services/test_app_service.py" = ["ARG002"]
|
|
||||||
"services/test_attachment_service.py" = ["ARG002"]
|
|
||||||
"services/test_conversation_variable_updater.py" = ["ARG002"]
|
|
||||||
"services/test_dataset_service_batch_update_document_status.py" = ["ARG002"]
|
|
||||||
"services/test_delete_archived_workflow_run.py" = ["ARG002"]
|
|
||||||
"services/test_document_service_rename_document.py" = ["ARG001"]
|
|
||||||
"services/test_end_user_service.py" = ["ARG002"]
|
|
||||||
"services/test_feature_service.py" = ["ARG002"]
|
|
||||||
"services/test_file_service.py" = ["ARG002"]
|
|
||||||
"services/test_file_service_zip_and_lookup.py" = ["TID251"]
|
|
||||||
"services/test_messages_clean_service.py" = ["ARG002", "S110"]
|
|
||||||
"services/test_metadata_partial_update.py" = ["ARG002"]
|
|
||||||
"services/test_metadata_service.py" = ["ARG002"]
|
|
||||||
"services/test_model_load_balancing_service.py" = ["ARG002"]
|
|
||||||
"services/test_model_provider_service.py" = ["ARG002"]
|
|
||||||
"services/test_ops_service.py" = ["ARG002"]
|
|
||||||
"services/test_webapp_auth_service.py" = ["ARG002"]
|
|
||||||
"services/test_webhook_service.py" = ["ARG002"]
|
|
||||||
"services/test_workflow_draft_variable_service.py" = ["ARG002"]
|
|
||||||
"services/test_workflow_run_service.py" = ["ARG002"]
|
|
||||||
"services/test_workflow_service.py" = ["ARG002"]
|
|
||||||
"services/test_workspace_service.py" = ["ARG002"]
|
|
||||||
"services/tools/test_api_tools_manage_service.py" = ["ARG002"]
|
|
||||||
"services/tools/test_mcp_tools_manage_service.py" = ["ARG002", "ARG005"]
|
|
||||||
"services/tools/test_tools_transform_service.py" = ["ARG002"]
|
|
||||||
"services/workflow/test_workflow_converter.py" = ["ARG002"]
|
|
||||||
"tasks/test_add_document_to_index_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_batch_clean_document_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_batch_create_segment_to_index_task.py" = ["ARG001", "ARG002"]
|
|
||||||
"tasks/test_clean_dataset_task.py" = ["T201"]
|
|
||||||
"tasks/test_clean_notion_document_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_create_segment_to_index_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_dataset_indexing_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_deal_dataset_vector_index_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_delete_segment_from_index_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_disable_segment_from_index_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_disable_segments_from_index_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_document_indexing_sync_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_document_indexing_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_document_indexing_update_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_duplicate_document_indexing_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_enable_segments_to_index_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_mail_change_mail_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_mail_email_code_login_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_mail_human_input_delivery_task.py" = ["ARG001"]
|
|
||||||
"tasks/test_mail_inner_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_mail_invite_member_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_mail_owner_transfer_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_mail_register_task.py" = ["ARG002"]
|
|
||||||
"tasks/test_rag_pipeline_run_tasks.py" = ["ARG002"]
|
|
||||||
"test_workflow_pause_integration.py" = ["T201"]
|
|
||||||
"trigger/conftest.py" = ["ANN401", "TID251"]
|
"trigger/conftest.py" = ["ANN401", "TID251"]
|
||||||
"trigger/test_trigger_e2e.py" = ["ANN401", "ARG001", "TID251"]
|
"trigger/test_trigger_e2e.py" = ["ANN401", "TID251", "ARG"]
|
||||||
"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG002"]
|
"controllers/console/app/test_app_apis.py" = ["ARG"]
|
||||||
"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG002"]
|
"controllers/console/app/test_app_import_api.py" = ["ARG"]
|
||||||
"workflow/nodes/code_executor/test_code_python3.py" = ["ARG002"]
|
"controllers/console/auth/test_oauth.py" = ["ARG"]
|
||||||
|
"controllers/console/auth/test_password_reset.py" = ["ARG"]
|
||||||
|
"controllers/console/datasets/test_data_source.py" = ["ARG"]
|
||||||
|
"controllers/console/test_apikey.py" = ["ARG"]
|
||||||
|
"controllers/console/workspace/test_tool_provider.py" = ["ARG"]
|
||||||
|
"controllers/mcp/test_mcp.py" = ["ARG"]
|
||||||
|
"controllers/openapi/test_app_dsl.py" = ["ARG"]
|
||||||
|
"controllers/openapi/test_workspaces.py" = ["ARG"]
|
||||||
|
"controllers/service_api/dataset/test_dataset.py" = ["ARG"]
|
||||||
|
"controllers/web/test_conversation.py" = ["ARG"]
|
||||||
|
"controllers/web/test_human_input_form.py" = ["ARG"]
|
||||||
|
"controllers/web/test_wraps.py" = ["ARG"]
|
||||||
|
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG"]
|
||||||
|
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG"]
|
||||||
|
"models/test_conversation_message_inputs.py" = ["ARG"]
|
||||||
|
"models/test_conversation_status_count.py" = ["ARG"]
|
||||||
|
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG"]
|
||||||
|
"repositories/test_workflow_run_repository.py" = ["ARG"]
|
||||||
|
"services/auth/test_api_key_auth_service.py" = ["ARG"]
|
||||||
|
"services/auth/test_auth_integration.py" = ["ARG"]
|
||||||
|
"services/dataset_collection_binding.py" = ["ARG"]
|
||||||
|
"services/dataset_service_update_delete.py" = ["ARG"]
|
||||||
|
"services/document_service_status.py" = ["ARG"]
|
||||||
|
"services/enterprise/test_account_deletion_sync.py" = ["ARG"]
|
||||||
|
"services/plugin/test_plugin_parameter_service.py" = ["ARG"]
|
||||||
|
"services/plugin/test_plugin_service.py" = ["ARG"]
|
||||||
|
"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG"]
|
||||||
|
"services/recommend_app/test_database_retrieval.py" = ["ARG"]
|
||||||
|
"services/test_account_service.py" = ["ARG"]
|
||||||
|
"services/test_advanced_prompt_template_service.py" = ["ARG"]
|
||||||
|
"services/test_annotation_service.py" = ["ARG"]
|
||||||
|
"services/test_api_based_extension_service.py" = ["ARG"]
|
||||||
|
"services/test_api_token_service.py" = ["ARG"]
|
||||||
|
"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_notion_document_task.py" = ["ARG"]
|
||||||
|
"tasks/test_create_segment_to_index_task.py" = ["ARG"]
|
||||||
|
"tasks/test_dataset_indexing_task.py" = ["ARG"]
|
||||||
|
"tasks/test_deal_dataset_vector_index_task.py" = ["ARG"]
|
||||||
|
"tasks/test_delete_segment_from_index_task.py" = ["ARG"]
|
||||||
|
"tasks/test_disable_segment_from_index_task.py" = ["ARG"]
|
||||||
|
"tasks/test_disable_segments_from_index_task.py" = ["ARG"]
|
||||||
|
"tasks/test_document_indexing_sync_task.py" = ["ARG"]
|
||||||
|
"tasks/test_document_indexing_task.py" = ["ARG"]
|
||||||
|
"tasks/test_document_indexing_update_task.py" = ["ARG"]
|
||||||
|
"tasks/test_duplicate_document_indexing_task.py" = ["ARG"]
|
||||||
|
"tasks/test_enable_segments_to_index_task.py" = ["ARG"]
|
||||||
|
"tasks/test_mail_change_mail_task.py" = ["ARG"]
|
||||||
|
"tasks/test_mail_email_code_login_task.py" = ["ARG"]
|
||||||
|
"tasks/test_mail_human_input_delivery_task.py" = ["ARG"]
|
||||||
|
"tasks/test_mail_inner_task.py" = ["ARG"]
|
||||||
|
"tasks/test_mail_invite_member_task.py" = ["ARG"]
|
||||||
|
"tasks/test_mail_owner_transfer_task.py" = ["ARG"]
|
||||||
|
"tasks/test_mail_register_task.py" = ["ARG"]
|
||||||
|
"tasks/test_rag_pipeline_run_tasks.py" = ["ARG"]
|
||||||
|
"test_workflow_pause_integration.py" = ["T201"]
|
||||||
|
"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG"]
|
||||||
|
"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG"]
|
||||||
|
"workflow/nodes/code_executor/test_code_python3.py" = ["ARG"]
|
||||||
"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,24 +1,42 @@
|
|||||||
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"
|
|
||||||
|
|
||||||
# Existing strict-mode debt. Remove a file when bringing it under strict checking.
|
# Verify project-excludes from the repo root:
|
||||||
|
# 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",
|
||||||
@@ -39,16 +57,22 @@ 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",
|
||||||
@@ -73,15 +97,20 @@ 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",
|
||||||
@@ -95,6 +124,7 @@ 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",
|
||||||
@@ -131,6 +161,7 @@ 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",
|
||||||
@@ -138,7 +169,6 @@ 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,7 +124,6 @@ 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()
|
||||||
@@ -143,9 +142,6 @@ 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,
|
||||||
@@ -1038,9 +1034,7 @@ class TestAppDslService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
imported_graph, warnings, retirement_candidates = AgentDslService(
|
imported_graph, warnings = AgentDslService(db_session_with_containers).import_workflow_packages(
|
||||||
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")},
|
||||||
@@ -1049,7 +1043,6 @@ 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,5 +1,4 @@
|
|||||||
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
|
||||||
@@ -30,7 +29,6 @@ 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
|
||||||
@@ -44,9 +42,6 @@ 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,11 +6,13 @@ 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 TenantAccountRole
|
from models import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus, 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
|
||||||
@@ -1075,8 +1077,9 @@ 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, db_session_with_containers: Session):
|
def test_delete_conversation(self, mock_delete_task, mock_cleanup_task, db_session_with_containers: Session):
|
||||||
"""
|
"""
|
||||||
Test conversation deletion with async cleanup.
|
Test conversation deletion with async cleanup.
|
||||||
|
|
||||||
@@ -1095,6 +1098,20 @@ 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(
|
||||||
@@ -1109,11 +1126,27 @@ 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,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -1145,17 +1178,22 @@ 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 place.
|
When a DB error occurs during deletion, the conversation row stays in
|
||||||
|
place, but any already-enqueued Agent backend cleanup remains a
|
||||||
|
best-effort terminal lifecycle action.
|
||||||
"""
|
"""
|
||||||
# Arrange
|
# Arrange
|
||||||
app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account(
|
app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account(
|
||||||
@@ -1165,6 +1203,20 @@ 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")):
|
||||||
@@ -1176,9 +1228,111 @@ class TestConversationServiceExport:
|
|||||||
session=db_session_with_containers,
|
session=db_session_with_containers,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert — related-data deletion is not scheduled.
|
# Assert — related-data deletion is not scheduled, but the backend
|
||||||
|
# cleanup task was already enqueued before the row delete failed.
|
||||||
mock_delete_task.delay.assert_not_called()
|
mock_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,13 +870,12 @@ 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, retirement_candidates = workflow_service.publish_workflow(
|
result = 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)
|
||||||
|
|||||||
@@ -1,430 +0,0 @@
|
|||||||
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."
|
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
"""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,7 +40,6 @@ 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,7 +23,6 @@ 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,7 +1,10 @@
|
|||||||
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 (
|
||||||
@@ -42,6 +45,8 @@ 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
|
||||||
@@ -66,7 +71,6 @@ 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.",
|
||||||
@@ -267,6 +271,89 @@ 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(
|
||||||
@@ -310,7 +397,6 @@ 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,
|
||||||
@@ -336,7 +422,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, "runtime": "runtime"}
|
assert shell.deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||||
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"
|
||||||
|
|
||||||
@@ -350,10 +436,7 @@ 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 == {
|
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||||
"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}
|
||||||
@@ -387,10 +470,7 @@ 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 == {
|
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||||
"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"
|
||||||
|
|
||||||
@@ -403,10 +483,7 @@ 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 == {
|
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||||
"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}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from agenton.compositor import CompositorSessionSnapshot
|
||||||
|
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||||
|
from agenton.layers.base import LifecycleState
|
||||||
|
from dify_agent.protocol import RunStatusResponse
|
||||||
|
|
||||||
|
from clients.agent_backend import (
|
||||||
|
AgentBackendError,
|
||||||
|
AgentBackendSessionCleanupPayload,
|
||||||
|
FakeAgentBackendRunClient,
|
||||||
|
RuntimeLayerSpec,
|
||||||
|
cleanup_agent_backend_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload() -> AgentBackendSessionCleanupPayload:
|
||||||
|
return AgentBackendSessionCleanupPayload(
|
||||||
|
session_snapshot=CompositorSessionSnapshot(
|
||||||
|
layers=[
|
||||||
|
LayerSessionSnapshot(
|
||||||
|
name="history",
|
||||||
|
lifecycle_state=LifecycleState.SUSPENDED,
|
||||||
|
runtime_state={},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")],
|
||||||
|
idempotency_key="cleanup-1",
|
||||||
|
metadata={"tenant_id": "tenant-1"},
|
||||||
|
timeout_seconds=15.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_agent_backend_session_runs_create_and_wait_until_success():
|
||||||
|
client = FakeAgentBackendRunClient(run_id="cleanup-run-1")
|
||||||
|
|
||||||
|
result = cleanup_agent_backend_session(payload=_payload(), client=client)
|
||||||
|
|
||||||
|
assert result.status == "succeeded"
|
||||||
|
assert result.cleanup_run_id == "cleanup-run-1"
|
||||||
|
assert client.request is not None
|
||||||
|
assert [layer.name for layer in client.request.composition.layers] == ["history"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_agent_backend_session_skips_when_client_is_missing():
|
||||||
|
result = cleanup_agent_backend_session(
|
||||||
|
payload=_payload(),
|
||||||
|
client=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "skipped"
|
||||||
|
assert result.reason == "no_agent_backend_client"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_agent_backend_session_skips_when_session_snapshot_is_missing():
|
||||||
|
payload = _payload().model_copy(update={"session_snapshot": None})
|
||||||
|
|
||||||
|
result = cleanup_agent_backend_session(payload=payload, client=FakeAgentBackendRunClient())
|
||||||
|
|
||||||
|
assert result.status == "skipped"
|
||||||
|
assert result.reason == "missing_session_snapshot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_agent_backend_session_skips_when_runtime_layer_specs_are_missing():
|
||||||
|
payload = _payload().model_copy(update={"runtime_layer_specs": []})
|
||||||
|
|
||||||
|
result = cleanup_agent_backend_session(payload=payload, client=FakeAgentBackendRunClient())
|
||||||
|
|
||||||
|
assert result.status == "skipped"
|
||||||
|
assert result.reason == "missing_runtime_layer_specs"
|
||||||
|
|
||||||
|
|
||||||
|
class _FailedStatusClient(FakeAgentBackendRunClient):
|
||||||
|
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
||||||
|
del timeout_seconds
|
||||||
|
return RunStatusResponse(
|
||||||
|
run_id=run_id,
|
||||||
|
status="failed",
|
||||||
|
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||||
|
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||||
|
error="snapshot mismatch",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_agent_backend_session_reports_failed_terminal_status():
|
||||||
|
client = _FailedStatusClient(run_id="cleanup-run-2")
|
||||||
|
|
||||||
|
result = cleanup_agent_backend_session(payload=_payload(), client=client)
|
||||||
|
|
||||||
|
assert result.status == "failed"
|
||||||
|
assert result.reason == "snapshot mismatch"
|
||||||
|
assert result.cleanup_run_id == "cleanup-run-2"
|
||||||
|
|
||||||
|
|
||||||
|
class _CreateRunFailureClient(FakeAgentBackendRunClient):
|
||||||
|
def create_run(self, request): # type: ignore[override]
|
||||||
|
del request
|
||||||
|
raise AgentBackendError("create run failed")
|
||||||
|
|
||||||
|
|
||||||
|
class _WaitRunFailureClient(FakeAgentBackendRunClient):
|
||||||
|
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
||||||
|
del run_id, timeout_seconds
|
||||||
|
raise AgentBackendError("wait run failed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_agent_backend_session_returns_failed_when_create_run_raises():
|
||||||
|
result = cleanup_agent_backend_session(payload=_payload(), client=_CreateRunFailureClient())
|
||||||
|
|
||||||
|
assert result.status == "failed"
|
||||||
|
assert result.reason == "create run failed"
|
||||||
|
assert result.cleanup_run_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_agent_backend_session_returns_failed_with_cleanup_run_id_when_wait_run_raises():
|
||||||
|
result = cleanup_agent_backend_session(
|
||||||
|
payload=_payload(),
|
||||||
|
client=_WaitRunFailureClient(run_id="cleanup-run-3"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "failed"
|
||||||
|
assert result.reason == "wait run failed"
|
||||||
|
assert result.cleanup_run_id == "cleanup-run-3"
|
||||||
@@ -85,42 +85,6 @@ 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(
|
||||||
@@ -812,7 +776,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_guard_ignore_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None:
|
def test_inline_noqa_suppression_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,
|
||||||
@@ -831,20 +795,20 @@ def test_inline_guard_ignore_with_explanatory_text_skips_added_getattr(tmp_path:
|
|||||||
"pkg/existing.py",
|
"pkg/existing.py",
|
||||||
"""
|
"""
|
||||||
def read_value(obj):
|
def read_value(obj):
|
||||||
return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr -- plugin-defined attributes
|
return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr needed for 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 "guard-ignore: no-new-getattr -- plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text(
|
assert "no-new-getattr needed for 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_guard_ignore_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None:
|
def test_inline_noqa_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,
|
||||||
@@ -863,10 +827,10 @@ def test_inline_guard_ignore_without_explanatory_text_is_not_sufficient(tmp_path
|
|||||||
"pkg/existing.py",
|
"pkg/existing.py",
|
||||||
"""
|
"""
|
||||||
def read_value(obj):
|
def read_value(obj):
|
||||||
return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr
|
return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
commit_all(tmp_path, "add bare guard ignore getattr")
|
commit_all(tmp_path, "add bare noqa getattr")
|
||||||
|
|
||||||
result = run_script(tmp_path, "--base-rev", base_rev)
|
result = run_script(tmp_path, "--base-rev", base_rev)
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ 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)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user