Compare commits

...
Author SHA1 Message Date
yyh 0b9e03c2c1 fix(agent-v2): preserve interrupted build input 2026-06-28 03:49:25 +08:00
autofix-ci[bot]andGitHub b34eb8f24b [autofix.ci] apply automated fixes 2026-06-27 19:42:43 +00:00
yyh 625cfad888 fix(agent-v2): fence stale build draft refresh 2026-06-28 03:36:34 +08:00
yyh 3859367e3a fix(agent-v2): cancel inline build refresh timer 2026-06-28 03:21:09 +08:00
yyh 92c0974e19 fix(agent-v2): fail explicit draft save commands 2026-06-28 03:14:51 +08:00
yyh f7d859afac fix(agent-v2): guard build draft refresh lifecycle 2026-06-28 03:12:34 +08:00
yyh b522f7df40 fix(agent-v2): align chat feature draft source 2026-06-28 03:07:16 +08:00
autofix-ci[bot]andGitHub 1787145f2a [autofix.ci] apply automated fixes 2026-06-27 19:00:10 +00:00
yyh f581dfc016 docs(agent-v2): align configure module boundary 2026-06-28 02:54:09 +08:00
yyh 26291a0f42 test(agent-v2): tighten state graph review coverage 2026-06-28 02:49:03 +08:00
yyh 80b9f5e083 fix(agent-v2): align inline build draft source 2026-06-28 01:55:05 +08:00
yyh bbebeec3dc fix(chat): settle stream read failures 2026-06-28 01:43:30 +08:00
yyh 6a69ec2a8a fix(agent-v2): honor debug refresh contract 2026-06-28 01:33:46 +08:00
autofix-ci[bot]andGitHub f874a6a019 [autofix.ci] apply automated fixes 2026-06-27 17:31:32 +00:00
yyh bcdd52a27b fix(agent-v2): reconcile publish state owners 2026-06-28 01:24:56 +08:00
autofix-ci[bot]andGitHub 32e9de8ae3 [autofix.ci] apply automated fixes 2026-06-27 17:17:33 +00:00
yyh 35c9e3e3d3 fix(agent-v2): close build draft state gaps 2026-06-28 01:12:04 +08:00
autofix-ci[bot]andGitHub d37783c895 [autofix.ci] apply automated fixes 2026-06-27 16:55:18 +00:00
yyh cceeabcb46 fix(agent-v2): align build draft lifecycle owners 2026-06-28 00:50:01 +08:00
yyh 8536c232ba fix(agent-v2): stabilize build chat run state 2026-06-28 00:35:16 +08:00
yyh 8383571c53 fix(agent-v2): preserve publish state for reverted drafts 2026-06-28 00:35:00 +08:00
yyh 953b42ac67 fix(agent-v2): clear publish action after reverted draft 2026-06-28 00:10:39 +08:00
yyh 4e069c1f23 fix(agent-v2): save effective model before build run 2026-06-28 00:07:08 +08:00
yyh 09b5d63c39 refactor: merge inline agent common translations 2026-06-28 00:05:05 +08:00
yyh 8f2cd4e5b0 fix(agent-v2): disable restart when chat reset is unavailable 2026-06-28 00:01:51 +08:00
yyh b57e5da995 fix(agent-v2): stabilize chat reset command 2026-06-27 23:52:51 +08:00
yyh daf13834a6 fix(agent-v2): stabilize build chat state 2026-06-27 23:47:19 +08:00
yyh 309777f8fc fix(agent-v2): skip unchanged draft save before build 2026-06-27 23:19:49 +08:00
yyh 184670f49d fix(agent-v2): clarify build draft action states 2026-06-27 23:10:58 +08:00
yyh 63aa8af5c1 refactor(agent-v2): scope configure conversation state 2026-06-27 23:09:31 +08:00
yyh ee30e59871 fix(agent-v2): keep build chat visible after conversation starts 2026-06-27 23:01:06 +08:00
yyh 0199589c3b refactor(agent-v2): clarify configure state graph 2026-06-27 22:57:36 +08:00
yyhandGitHub 7bb94cb6fe fix: gate deployments in route layout (#38078) 2026-06-27 13:35:37 +00:00
yyhandGitHub 484633d261 fix: prevent exiting toasts from blocking page clicks (#38063) 2026-06-27 07:35:25 +00:00
Stephen ZhouGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
a14310fc62 refactor(web): organize deployment feature state (#38065)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-27 07:34:46 +00:00
Byron.wangandGitHub 8218694691 fix: improve workflow run archive idempotency and batching (#38027) 2026-06-27 11:09:57 +08:00
yyhandGitHub 17bee5fb32 fix: improve members role chip accessibility (#38037) 2026-06-26 18:40:03 +00:00
yyhandGitHub 446b3962c1 fix: remove aria-busy from loading button (#38036) 2026-06-26 18:39:42 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
00b4cdc68e chore(i18n): sync translations with en-US (#38035)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-06-26 13:54:30 +00:00
Dx.andGitHub 449b46b863 fix(web): download markdown file-preview links as attachments (#38030) 2026-06-26 13:46:44 +00:00
8d09b32cd5 chore: inject session (#37941)
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-06-26 13:42:47 +00:00
盐粒 YanliGitHubJoelyyhYansong Zhangautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
52c106b532 feat(agent-v2): sync nightly updates to main (2026-06-25) (#37915)
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: Yansong Zhang <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-26 13:13:35 +00:00
5b8679468d docs(web): fix testing guide link (#38006)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-06-26 12:50:12 +00:00
EvanandGitHub 4fbfedd287 refactor: replace patch logger with caplog in test_version.py (#38029) 2026-06-26 12:07:44 +00:00
CyberGitHubClaude Opus 4.7yyhyyhhjlarryautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
051f4b32e3 feat(web): add customizable input placeholder for Agent/Chatflow/Chatbot web app (#37790)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-26 09:57:31 +00:00
非法操作andGitHub e22fd9efd6 fix: plugin installation task popover layout when some failed too long (#38000) 2026-06-26 09:36:36 +00:00
35eeb743d1 fix(web): capture blog UTM/slug attribution reliably under CSP (#38022)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-06-26 08:35:43 +00:00
林玮 (Jade Lin)andGitHub 1dbda1463e feat(api): cache workflow provider configurations (#37980) 2026-06-26 08:04:37 +00:00
Mr_xieGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
3f2ef24755 refactor: pass db session into service calls (#37403) (#38016)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-26 07:53:27 +00:00
613 changed files with 29668 additions and 8178 deletions
@@ -25,6 +25,8 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
- Module README whitelist: `@/service/client`, `@/next/*`.
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
+55 -44
View File
@@ -78,11 +78,22 @@ def _filter_snapshot_to_specs(
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
def _shell_layer_deps(*, include_drive: bool) -> dict[str, str]:
deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
if include_drive:
deps["drive"] = DIFY_DRIVE_LAYER_ID
return deps
def _shell_layer_deps() -> dict[str, str]:
return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
def _drive_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _shell_config_with_drive_ref(
shell_config: DifyShellLayerConfig | None,
drive_config: DifyDriveLayerConfig | None,
) -> DifyShellLayerConfig:
config = shell_config or DifyShellLayerConfig()
if drive_config is None:
return config
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
class AgentBackendModelConfig(BaseModel):
@@ -263,14 +274,29 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = run_input.include_shell or run_input.drive_config is not None
if include_shell:
# Sandboxed bash workspace (dify.shell). It enters before drive so
# drive can materialize mentioned targets with `dify-agent drive pull`
# in the same shell-visible filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): a config-only index;
# the agent pulls listed entries through the back proxy by drive_ref.
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
deps=_drive_layer_deps(),
metadata=run_input.metadata,
config=run_input.drive_config,
)
@@ -312,7 +338,7 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
if run_input.knowledge is not None and run_input.knowledge.sets:
layers.append(
RunLayerSpec(
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
@@ -336,21 +362,6 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). Depends on execution_context
# so the agent server can mint per-command Agent Stub env, and on
# drive when present so that env points at /mnt/drive/<drive_ref>.
# shellctl connection itself is server-injected.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
metadata=run_input.metadata,
config=run_input.shell_config or DifyShellLayerConfig(),
)
)
if run_input.output is not None:
layers.append(
RunLayerSpec(
@@ -445,7 +456,7 @@ class AgentBackendRunRequestBuilder:
name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "workflow_node_job"},
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
config=PromptLayerConfig(user=run_input.workflow_node_job_prompt),
),
RunLayerSpec(
name=WORKFLOW_USER_PROMPT_LAYER_ID,
@@ -462,14 +473,29 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = run_input.include_shell or run_input.drive_config is not None
if include_shell:
# Sandboxed bash workspace (dify.shell). It enters before drive so
# drive can materialize mentioned targets with `dify-agent drive pull`
# in the same shell-visible filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): a config-only index;
# the agent pulls listed entries through the back proxy by drive_ref.
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
deps=_drive_layer_deps(),
metadata=run_input.metadata,
config=run_input.drive_config,
)
@@ -513,7 +539,7 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
if run_input.knowledge is not None and run_input.knowledge.sets:
layers.append(
RunLayerSpec(
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
@@ -537,21 +563,6 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). Depends on execution_context
# so the agent server can mint per-command Agent Stub env, and on
# drive when present so that env points at /mnt/drive/<drive_ref>.
# shellctl connection itself is server-injected.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
metadata=run_input.metadata,
config=run_input.shell_config or DifyShellLayerConfig(),
)
)
if run_input.output is not None:
layers.append(
RunLayerSpec(
+29 -2
View File
@@ -35,6 +35,12 @@ class WorkflowRunArchiveTenantPlan(TypedDict):
unpaid_tenant_ids: list[str]
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
if value.tzinfo is None:
return value.replace(tzinfo=datetime.UTC)
return value.astimezone(datetime.UTC)
def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
if not prefixes:
return []
@@ -156,11 +162,16 @@ def _resolve_archive_time_range(
raise click.UsageError("Choose either day offsets or explicit dates, not both.")
if from_days_ago <= to_days_ago:
raise click.UsageError("--from-days-ago must be greater than --to-days-ago.")
now = datetime.datetime.now()
now = datetime.datetime.now(datetime.UTC)
start_from = now - datetime.timedelta(days=from_days_ago)
end_before = now - datetime.timedelta(days=to_days_ago)
before_days = 0
if start_from is not None:
start_from = _normalize_utc_datetime(start_from)
if end_before is not None:
end_before = _normalize_utc_datetime(end_before)
if start_from and end_before and start_from >= end_before:
raise click.UsageError("--start-from must be earlier than --end-before.")
@@ -402,6 +413,13 @@ def archive_workflow_runs_plan(
fg="white",
)
)
click.echo(
click.style(
"fixed_archive_window="
f"{start_from.isoformat() if start_from else 'unbounded'},{plan_end_before.isoformat()}",
fg="white",
)
)
click.echo("tenant_prefix,total_tenants,workflow_runs,workflow_node_executions,paid_tenants,unpaid_tenants")
for row in rows:
click.echo(
@@ -451,7 +469,7 @@ def archive_workflow_runs_plan(
default=None,
help="Archive runs created before this timestamp (UTC if no timezone).",
)
@click.option("--batch-size", default=100, show_default=True, help="Maximum workflow runs per archive bundle.")
@click.option("--batch-size", default=10000, show_default=True, help="Maximum workflow runs per archive bundle.")
@click.option(
"--workers",
default=1,
@@ -521,6 +539,7 @@ def archive_workflow_runs(
)
)
uses_relative_window = start_from is None and end_before is None
try:
before_days, start_from, end_before = _resolve_archive_time_range(
before_days=before_days,
@@ -546,6 +565,14 @@ def archive_workflow_runs(
if delete_after_archive:
click.echo(click.style("delete-after-archive is not supported by bundle archive.", fg="red"))
return
if uses_relative_window:
click.echo(
click.style(
"Relative archive windows are evaluated at command start. For multi-day prefix/shard rollout, "
"reuse absolute --start-from/--end-before values from archive-workflow-runs-plan.",
fg="yellow",
)
)
try:
tenant_plan = _resolve_archive_tenant_ids_from_plan(
+3 -3
View File
@@ -36,8 +36,8 @@ class AgentBackendConfig(BaseSettings):
description=(
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
"into Agent runs. The declaration is an index only — the agent backend "
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
"until the agent backend registers the dify.drive layer type."
"pulls the actual SKILL.md / files through the back proxy. Set this to "
"false only when temporarily rolling back the drive integration."
),
default=False,
default=True,
)
+1
View File
@@ -183,6 +183,7 @@ class Site(BaseModel):
description: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
input_placeholder: str | None = None
custom_disclaimer: str | None = None
default_language: str
show_workflow_steps: bool
+11 -1
View File
@@ -6,5 +6,15 @@ from services.agent.roster_service import AgentRosterService
def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App:
"""Resolve the hidden Agent App backing an Agent Console resource."""
"""Resolve a roster Agent's public Agent App."""
return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
def resolve_agent_runtime_app_model(*, tenant_id: str, agent_id: UUID) -> App:
"""Resolve the App that backs an Agent runtime surface.
This accepts both roster Agent Apps and workflow-only inline Agents with a
hidden backing App.
"""
return AgentRosterService(db.session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
+20 -16
View File
@@ -1,10 +1,10 @@
from uuid import UUID
from flask import request
from flask_restx import Resource
from controllers.common.schema import register_response_schema_models, register_schema_models
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.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -28,9 +28,15 @@ from libs.login import login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload
from services.entities.agent_entities import (
ComposerSavePayload,
WorkflowAgentComposerQuery,
WorkflowComposerCopyFromRosterPayload,
)
register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload)
register_schema_models(
console_ns, ComposerSavePayload, WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload
)
register_response_schema_models(
console_ns,
AgentAppComposerResponse,
@@ -41,27 +47,26 @@ register_response_schema_models(
)
def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str:
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
class WorkflowAgentComposerApi(Resource):
@console_ns.response(
200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@console_ns.doc(params=query_params_from_model(WorkflowAgentComposerQuery))
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, node_id: str):
query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True))
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
snapshot_id=query.snapshot_id,
),
)
@@ -137,6 +142,7 @@ class WorkflowAgentComposerValidateApi(Resource):
def post(self, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_publish_payload(payload)
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
@@ -228,10 +234,9 @@ class AgentComposerApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(
AgentAppComposerResponse,
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id),
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)),
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@@ -244,13 +249,12 @@ class AgentComposerApi(Resource):
@with_current_user_id
@with_current_tenant_id
def put(self, tenant_id: str, account_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
AgentAppComposerResponse,
AgentComposerService.save_agent_app_composer(
AgentComposerService.save_agent_composer(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
account_id=account_id,
payload=payload,
),
@@ -268,9 +272,10 @@ class AgentComposerValidateApi(Resource):
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id))
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_publish_payload(payload)
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
@@ -290,12 +295,11 @@ class AgentComposerCandidatesApi(Resource):
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
user_id=current_user_id,
),
)
+174 -11
View File
@@ -7,7 +7,7 @@ from sqlalchemy import func, select
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.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_app_model, resolve_agent_runtime_app_model
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
from controllers.console.app.app import (
AppDetailWithSite as GenericAppDetailWithSite,
@@ -54,8 +54,10 @@ from libs.datetime_utils import parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.agent import Agent, AgentStatus
from models.enums import ApiTokenType
from models.model import ApiToken, App, IconType
from services.agent.composer_service import AgentComposerService
from services.agent.errors import AgentNotFoundError
from services.agent.observability_service import (
AgentLogQueryParams,
@@ -65,7 +67,7 @@ from services.agent.observability_service import (
from services.agent.roster_service import AgentRosterService
from services.app_service import AppListParams, AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import RosterListQuery
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
from services.feature_service import FeatureService
@@ -232,6 +234,8 @@ class AgentStatisticsQuery(BaseModel):
class AgentAppPartial(GenericAppPartial):
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
debug_conversation_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
@@ -241,6 +245,8 @@ class AgentAppPartial(GenericAppPartial):
class AgentAppDetailWithSite(GenericAppDetailWithSite):
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
debug_conversation_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
@@ -250,6 +256,36 @@ class AgentDebugConversationRefreshResponse(BaseModel):
debug_conversation_id: str
class AgentPublishPayload(BaseModel):
version_note: str | None = Field(default=None, description="Optional note for this published Agent version")
class AgentPublishResponse(BaseModel):
result: str
active_config_snapshot_id: str
active_config_snapshot: dict[str, object] | None = None
draft: dict[str, object] | None = None
class AgentBuildDraftCheckoutPayload(BaseModel):
force: bool = Field(default=False, description="Overwrite the existing current-user build draft")
class AgentBuildDraftResponse(BaseModel):
variant: str
draft: dict[str, object]
agent_soul: dict[str, object]
class AgentBuildDraftApplyResponse(BaseModel):
result: str
draft: dict[str, object]
class AgentSimpleResultResponse(BaseModel):
result: str
class AgentAppPagination(GenericAppPagination):
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
validation_alias=AliasChoices("items", "data")
@@ -261,6 +297,9 @@ register_schema_models(
AgentAppCreatePayload,
AgentAppUpdatePayload,
AgentAppCopyPayload,
AgentPublishPayload,
AgentBuildDraftCheckoutPayload,
ComposerSavePayload,
AgentApiStatusPayload,
AgentInviteOptionsQuery,
AgentLogsQuery,
@@ -277,6 +316,10 @@ register_response_schema_models(
AgentAppDetailWithSite,
AgentAppPartial,
AgentDebugConversationRefreshResponse,
AgentPublishResponse,
AgentBuildDraftResponse,
AgentBuildDraftApplyResponse,
AgentSimpleResultResponse,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentConfigSnapshotRestoreResponse,
@@ -294,7 +337,7 @@ def _agent_roster_service() -> AgentRosterService:
return AgentRosterService(db.session)
def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: str | None = None) -> dict:
"""Serialize an Agent App detail using roster-only DTOs.
`/agent` responses are roster-shaped rather than raw app-shaped: `id`
@@ -311,11 +354,23 @@ def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
roster_service = _agent_roster_service()
payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
agent = (
db.session.scalar(
select(Agent).where(
Agent.tenant_id == app_model.tenant_id,
Agent.id == agent_id,
Agent.status == AgentStatus.ACTIVE,
)
)
if agent_id
else roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
)
if not agent:
raise AgentNotFoundError()
payload.pop("bound_agent_id", None)
payload["app_id"] = str(app_model.id)
payload["app_id"] = agent.app_id
payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent)
payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id)
payload["id"] = agent.id
payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id(
tenant_id=app_model.tenant_id,
@@ -365,6 +420,8 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_u
agent = agents_by_app_id.get(app_id)
if agent:
item["app_id"] = app_id
item["backing_app_id"] = agent.backing_app_id or app_id
item["hidden_app_backed"] = False
item["id"] = agent.id
item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id)
item["role"] = agent.role or ""
@@ -516,8 +573,8 @@ class AgentAppApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _serialize_agent_app_detail(app_model, current_user=current_user)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _serialize_agent_app_detail(app_model, current_user=current_user, agent_id=str(agent_id))
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@@ -583,6 +640,112 @@ class AgentDebugConversationRefreshApi(Resource):
)
@console_ns.route("/agent/<uuid:agent_id>/publish")
class AgentPublishApi(Resource):
@console_ns.expect(console_ns.models[AgentPublishPayload.__name__])
@console_ns.response(200, "Agent draft published", console_ns.models[AgentPublishResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
args = AgentPublishPayload.model_validate(console_ns.payload or {})
return AgentComposerService.publish_agent_app_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
version_note=args.version_note,
)
@console_ns.route("/agent/<uuid:agent_id>/build-draft/checkout")
class AgentBuildDraftCheckoutApi(Resource):
@console_ns.expect(console_ns.models[AgentBuildDraftCheckoutPayload.__name__])
@console_ns.response(200, "Agent build draft checked out", console_ns.models[AgentBuildDraftResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {})
return AgentComposerService.checkout_agent_app_build_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
force=args.force,
)
@console_ns.route("/agent/<uuid:agent_id>/build-draft")
class AgentBuildDraftApi(Resource):
@console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
return AgentComposerService.load_agent_app_build_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(200, "Agent build draft saved", console_ns.models[AgentBuildDraftResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def put(self, tenant_id: str, current_user: Account, agent_id: UUID):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_agent_app_build_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
payload=payload,
)
@console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
return AgentComposerService.discard_agent_app_build_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
)
@console_ns.route("/agent/<uuid:agent_id>/build-draft/apply")
class AgentBuildDraftApplyApi(Resource):
@console_ns.response(200, "Agent build draft applied", console_ns.models[AgentBuildDraftApplyResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
return AgentComposerService.apply_agent_app_build_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
)
@console_ns.route("/agent/<uuid:agent_id>/copy")
class AgentAppCopyApi(Resource):
@console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__])
@@ -712,7 +875,7 @@ class AgentLogsApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
query_data["sources"] = _multi_query_values("sources", "source")
query_data["statuses"] = _multi_query_values("statuses", "status")
@@ -749,7 +912,7 @@ class AgentLogMessagesApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
query_data["sources"] = _multi_query_values("sources", "source")
query_data["statuses"] = _multi_query_values("statuses", "status")
@@ -786,7 +949,7 @@ class AgentLogSourcesApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id))
return dump_response(AgentLogSourceListResponse, payload)
@@ -805,7 +968,7 @@ class AgentStatisticsSummaryApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True))
timezone = current_user.timezone or "UTC"
start, end = _parse_observability_time_range(query.start, query.end, current_user)
+6 -6
View File
@@ -13,7 +13,7 @@ from controllers.common.schema import (
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_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.wraps import (
RBACPermission,
@@ -351,7 +351,7 @@ class AgentSkillUploadByAgentApi(Resource):
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
@@ -394,7 +394,7 @@ class AgentDriveFilesByAgentApi(Resource):
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
@console_ns.doc("delete_agent_drive_file_by_agent")
@@ -407,7 +407,7 @@ class AgentDriveFilesByAgentApi(Resource):
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
@@ -454,7 +454,7 @@ class AgentSkillByAgentApi(Resource):
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False)
@@ -494,7 +494,7 @@ class AgentSkillInferToolsByAgentApi(Resource):
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID, slug: str):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
@@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
@@ -87,7 +87,7 @@ class AgentAppFeatureConfigResource(Resource):
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
new_app_model_config = AgentAppFeatureConfigService.update_features(
@@ -22,7 +22,7 @@ from controllers.common.schema import (
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_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.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
@@ -144,7 +144,7 @@ class AgentAppSandboxListResource(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxListQuery)
try:
result = AgentAppSandboxService().list_files(
@@ -169,7 +169,7 @@ class AgentAppSandboxReadResource(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxFileQuery)
try:
result = AgentAppSandboxService().read_file(
@@ -194,7 +194,7 @@ class AgentAppSandboxUploadResource(Resource):
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
try:
result = AgentAppSandboxService().upload_file(
@@ -25,7 +25,7 @@ from controllers.common.schema import (
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_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.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
@@ -182,7 +182,7 @@ class AgentDriveListByAgentApi(Resource):
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveListByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
except AgentDriveError as exc:
@@ -201,7 +201,7 @@ class AgentDriveSkillListByAgentApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
except AgentDriveError as exc:
@@ -220,7 +220,7 @@ class AgentDriveSkillInspectByAgentApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID, skill_path: str):
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
return _json_response(
AgentDriveService().inspect_skill(
@@ -245,7 +245,7 @@ class AgentDrivePreviewByAgentApi(Resource):
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
@@ -264,7 +264,7 @@ class AgentDriveDownloadByAgentApi(Resource):
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
+4 -3
View File
@@ -331,7 +331,7 @@ class ModelConfig(ResponseModel):
return to_timestamp(value)
class Site(ResponseModel):
class AppDetailSiteResponse(ResponseModel):
access_token: str | None = Field(default=None, validation_alias="code")
code: str | None = None
title: str | None = None
@@ -345,6 +345,7 @@ class Site(ResponseModel):
customize_domain: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
input_placeholder: str | None = None
custom_disclaimer: str | None = None
customize_token_strategy: str | None = None
prompt_public: bool | None = None
@@ -461,7 +462,7 @@ class AppDetailWithSite(AppDetail):
api_base_url: str | None = None
max_active_requests: int | None = None
deleted_tools: list[DeletedTool] = Field(default_factory=list)
site: Site | None = None
site: AppDetailSiteResponse | None = None
# For Agent App type: the roster Agent backing this app (None otherwise).
bound_agent_id: str | None = None
# For Agent App responses exposed through /agent.
@@ -546,7 +547,7 @@ register_schema_models(
WorkflowPartial,
ModelConfigPartial,
ModelConfig,
Site,
AppDetailSiteResponse,
DeletedTool,
AppDetail,
AppExportResponse,
+7 -3
View File
@@ -11,7 +11,7 @@ import services
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import (
AppUnavailableError,
CompletionRequestError,
@@ -93,6 +93,10 @@ class ChatMessagePayload(BaseMessagePayload):
query: str = Field(..., description="User query")
conversation_id: str | None = Field(default=None, description="Conversation ID")
parent_message_id: str | None = Field(default=None, description="Parent message ID")
draft_type: Literal["draft", "debug_build"] = Field(
default="draft",
description="Agent App debug config source. Use debug_build while the Agent is in build mode.",
)
@field_validator("conversation_id", "parent_message_id")
@classmethod
@@ -218,7 +222,7 @@ class AgentChatMessageApi(Resource):
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _create_chat_message(
current_tenant_id=current_tenant_id,
current_user=current_user,
@@ -254,7 +258,7 @@ class AgentChatMessageStopApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
+5 -5
View File
@@ -13,7 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes
from controllers.common.fields import SimpleResultResponse, TextFileResponse
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.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import (
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
@@ -214,7 +214,7 @@ class AgentChatMessageListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _list_chat_messages(app_model=app_model, current_user=current_user)
@@ -250,7 +250,7 @@ class AgentMessageFeedbackApi(Resource):
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _update_message_feedback(current_user=current_user, app_model=app_model)
@@ -315,7 +315,7 @@ class AgentMessageSuggestedQuestionApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
@@ -393,7 +393,7 @@ class AgentMessageApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _get_message_detail(app_model=app_model, message_id=message_id)
+3
View File
@@ -40,6 +40,7 @@ class AppSiteUpdatePayload(BaseModel):
customize_domain: str | None = Field(default=None)
copyright: str | None = Field(default=None)
privacy_policy: str | None = Field(default=None)
input_placeholder: str | None = Field(default=None)
custom_disclaimer: str | None = Field(default=None)
customize_token_strategy: Literal["must", "allow", "not_allow"] | None = Field(default=None)
prompt_public: bool | None = Field(default=None)
@@ -66,6 +67,7 @@ class AppSiteResponse(ResponseModel):
customize_domain: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
input_placeholder: str | None = None
custom_disclaimer: str | None = None
customize_token_strategy: str
prompt_public: bool
@@ -110,6 +112,7 @@ class AppSite(Resource):
"customize_domain",
"copyright",
"privacy_policy",
"input_placeholder",
"custom_disclaimer",
"customize_token_strategy",
"prompt_public",
+3 -2
View File
@@ -16,6 +16,7 @@ from controllers.console.wraps import (
with_current_user,
)
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from libs.login import login_required
from models import Account
from services.billing_service import BillingService
@@ -50,7 +51,7 @@ class Subscription(Resource):
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True))
BillingService.is_tenant_owner_or_admin(current_user)
BillingService.is_tenant_owner_or_admin(db.session, current_user)
return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id)
@@ -64,7 +65,7 @@ class Invoices(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
BillingService.is_tenant_owner_or_admin(current_user)
BillingService.is_tenant_owner_or_admin(db.session, current_user)
return BillingService.get_invoices(current_user.email, current_tenant_id)
+4 -4
View File
@@ -602,7 +602,7 @@ class DatasetApi(Resource):
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
@@ -774,7 +774,7 @@ class DatasetQueryApi(Resource):
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -915,7 +915,7 @@ class DatasetRelatedAppListApi(Resource):
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -1194,7 +1194,7 @@ class DatasetPermissionUserListApi(Resource):
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -186,7 +186,7 @@ class DocumentResource(Resource):
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -206,7 +206,7 @@ class DocumentResource(Resource):
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -247,7 +247,7 @@ class GetProcessRuleApi(Resource):
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -322,7 +322,7 @@ class DatasetDocumentListApi(Resource):
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -431,7 +431,7 @@ class DatasetDocumentListApi(Resource):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -1173,7 +1173,7 @@ class DocumentStatusApi(DocumentResource):
DatasetService.check_dataset_model_setting(dataset)
# check user's permission
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
document_ids = request.args.getlist("document_id")
@@ -1440,7 +1440,7 @@ class DocumentGenerateSummaryApi(Resource):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -1537,7 +1537,7 @@ class DocumentSummaryStatusApi(DocumentResource):
# Check permissions
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -181,7 +181,7 @@ class DatasetDocumentSegmentListApi(Resource):
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -302,7 +302,7 @@ class DatasetDocumentSegmentListApi(Resource):
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
SegmentService.delete_segments(segment_ids, document, dataset)
@@ -345,7 +345,7 @@ class DatasetDocumentSegmentApi(Resource):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
@@ -421,7 +421,7 @@ class DatasetDocumentSegmentAddApi(Resource):
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
# validate args
@@ -494,7 +494,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
# validate args
@@ -550,7 +550,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
SegmentService.delete_segment(segment, document, dataset)
@@ -687,7 +687,7 @@ class ChildChunkAddApi(Resource):
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
# validate args
@@ -789,7 +789,7 @@ class ChildChunkAddApi(Resource):
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
# validate args
@@ -862,7 +862,7 @@ class ChildChunkUpdateApi(Resource):
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
try:
@@ -930,7 +930,7 @@ class ChildChunkUpdateApi(Resource):
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
# validate args
+1 -1
View File
@@ -382,7 +382,7 @@ class ExternalKnowledgeHitTestingApi(Resource):
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -90,7 +90,7 @@ class DatasetsHitTestingBase:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
+5 -5
View File
@@ -64,7 +64,7 @@ class DatasetMetadataCreateApi(Resource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
metadata = MetadataService.create_metadata(
db.session(), dataset_id_str, metadata_args, current_user, current_tenant_id
@@ -108,7 +108,7 @@ class DatasetMetadataApi(Resource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
metadata = MetadataService.update_metadata_name(
db.session(), dataset_id_str, metadata_id_str, name, current_user, current_tenant_id
@@ -128,7 +128,7 @@ class DatasetMetadataApi(Resource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
@@ -165,7 +165,7 @@ class DatasetMetadataBuiltInFieldActionApi(Resource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
match action:
case "enable":
@@ -194,7 +194,7 @@ class DocumentMetadataEditApi(Resource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
metadata_args = MetadataOperationData.model_validate(console_ns.payload or {})
@@ -18,6 +18,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
@@ -352,7 +353,7 @@ class ModelProviderPaymentCheckoutUrlApi(Resource):
def get(self, current_tenant_id: str, current_user: Account, provider: str):
if provider != "anthropic":
raise ValueError(f"provider name {provider} is invalid")
BillingService.is_tenant_owner_or_admin(current_user)
BillingService.is_tenant_owner_or_admin(db.session, current_user)
data = BillingService.get_model_provider_payment_link(
provider_name=provider,
tenant_id=current_tenant_id,
+2 -1
View File
@@ -14,11 +14,12 @@ api = ExternalApi(
files_ns = Namespace("files", description="File operations", path="/")
from . import image_preview, tool_files, upload
from . import agent_drive_archive, image_preview, tool_files, upload
api.add_namespace(files_ns)
__all__ = [
"agent_drive_archive",
"api",
"bp",
"files_ns",
@@ -0,0 +1,67 @@
from urllib.parse import quote
from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from controllers.common.file_response import enforce_download_for_html
from controllers.common.schema import register_schema_models
from controllers.files import files_ns
from models.agent import AgentDriveFileKind
from services.agent_drive_service import AgentDriveError, AgentDriveService
class AgentDriveArchiveMemberQuery(BaseModel):
tenant_id: str = Field(..., description="Tenant ID")
agent_id: str = Field(..., description="Agent ID")
key: str = Field(..., description="Virtual drive key")
archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind")
archive_file_id: str = Field(..., description="Archive file id")
member_path: str = Field(..., description="Zip member path")
timestamp: str = Field(..., description="Unix timestamp")
nonce: str = Field(..., description="Random nonce")
sign: str = Field(..., description="HMAC signature")
as_attachment: bool = Field(default=False, description="Download as attachment")
register_schema_models(files_ns, AgentDriveArchiveMemberQuery)
@files_ns.route("/agent-drive/archive-member")
class AgentDriveArchiveMemberApi(Resource):
@files_ns.doc("get_agent_drive_archive_member")
@files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters")
def get(self):
args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True))
if not AgentDriveService.verify_archive_member_signature(
tenant_id=args.tenant_id,
agent_id=args.agent_id,
key=args.key,
archive_file_kind=args.archive_file_kind,
archive_file_id=args.archive_file_id,
member_path=args.member_path,
timestamp=args.timestamp,
nonce=args.nonce,
sign=args.sign,
):
raise Forbidden("Invalid request.")
try:
payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request(
tenant_id=args.tenant_id,
agent_id=args.agent_id,
key=args.key,
archive_file_kind=args.archive_file_kind,
archive_file_id=args.archive_file_id,
member_path=args.member_path,
)
except AgentDriveError as exc:
raise NotFound(exc.message) from exc
response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
response.headers["Content-Length"] = str(len(payload))
if args.as_attachment and filename:
encoded_filename = quote(filename)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
return response
@@ -565,7 +565,7 @@ class DatasetApi(DatasetApiResource):
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
data = _dump_service_dataset_detail(dataset)
@@ -819,7 +819,7 @@ class DocumentStatusApi(DatasetApiResource):
# Check user's permission
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -84,7 +84,7 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
metadata = MetadataService.create_metadata(db.session(), dataset_id_str, metadata_args)
return dump_response(DatasetMetadataResponse, metadata), 201
@@ -157,7 +157,7 @@ class DatasetMetadataServiceApi(DatasetApiResource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
metadata = MetadataService.update_metadata_name(db.session(), dataset_id_str, metadata_id_str, payload.name)
return dump_response(DatasetMetadataResponse, metadata), 200
@@ -192,7 +192,7 @@ class DatasetMetadataServiceApi(DatasetApiResource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
return "", 204
@@ -260,7 +260,7 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
match action:
case "enable":
@@ -306,7 +306,7 @@ class DocumentMetadataEditServiceApi(DatasetApiResource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
metadata_args = MetadataOperationData.model_validate(service_api_ns.payload or {})
+29 -5
View File
@@ -14,7 +14,7 @@ from fields.base import ResponseModel
from libs.helper import AppIconUrlField
from models.account import TenantStatus
from models.model import App, EndUser, Site
from services.feature_service import FeatureService
from services.feature_service import FeatureModel, FeatureService
class AppSiteModelConfigResponse(ResponseModel):
@@ -38,6 +38,7 @@ class AppSiteResponse(ResponseModel):
description: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
input_placeholder: str | None = None
custom_disclaimer: str | None = None
default_language: str | None = None
prompt_public: bool | None = None
@@ -84,6 +85,7 @@ class AppSiteApi(WebApiResource):
"description": fields.String,
"copyright": fields.String,
"privacy_policy": fields.String,
"input_placeholder": fields.String,
"custom_disclaimer": fields.String,
"default_language": fields.String,
"prompt_public": fields.Boolean,
@@ -127,9 +129,15 @@ class AppSiteApi(WebApiResource):
if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
return AppSiteInfo(app_model.tenant, app_model, site, end_user.id, can_replace_logo)
return AppSiteInfo(
app_model.tenant,
app_model,
serialize_runtime_site(site, features),
end_user.id,
features.can_replace_logo,
)
class AppSiteInfo:
@@ -164,7 +172,23 @@ def serialize_site(site: Site) -> dict[str, Any]:
return cast(dict[str, Any], marshal(site, AppSiteApi.site_fields))
def serialize_runtime_site(site: Site, features: FeatureModel) -> dict[str, Any]:
site_payload = serialize_site(site)
if not features.billing.enabled or features.webapp_copyright_enabled:
return site_payload
site_payload["copyright"] = None
site_payload["input_placeholder"] = None
return site_payload
def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]:
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, can_replace_logo)
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
app_site_info = AppSiteInfo(
app_model.tenant,
app_model,
serialize_runtime_site(site, features),
end_user_id,
features.can_replace_logo,
)
return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields))
+130 -27
View File
@@ -16,7 +16,7 @@ from collections.abc import Generator, Mapping
from typing import Any
from flask import Flask, current_app
from sqlalchemy import select
from sqlalchemy import and_, or_, select
from clients.agent_backend import AgentBackendRunEventAdapter
from clients.agent_backend.factory import create_agent_backend_run_client
@@ -42,7 +42,15 @@ from core.app.llm.model_access import build_dify_model_access
from core.ops.ops_trace_manager import TraceQueueManager
from extensions.ext_database import db
from models import Account, App, EndUser, Message
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigSnapshot,
AgentScope,
AgentSource,
AgentStatus,
)
from models.agent_config_entities import AgentSoulConfig
from services.conversation_service import ConversationService
@@ -73,10 +81,15 @@ class AgentAppGenerator(MessageBasedAppGenerator):
inputs = args["inputs"]
# Resolve the bound roster Agent + its current Agent Soul snapshot.
agent, snapshot, agent_soul = self._resolve_agent(app_model)
agent, agent_config_id, agent_soul = self._resolve_agent(
app_model,
invoke_from=invoke_from,
draft_type=args.get("draft_type"),
user=user,
)
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
invoke_from=invoke_from,
snapshot_id=snapshot.id,
snapshot_id=agent_config_id,
)
conversation = None
@@ -123,7 +136,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
call_depth=0,
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=snapshot.id,
agent_config_snapshot_id=agent_config_id,
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
)
@@ -179,7 +192,12 @@ class AgentAppGenerator(MessageBasedAppGenerator):
persisted to the conversation. Live streaming to a reconnected client is
out of scope here the message is persisted and can be re-fetched.
"""
agent, snapshot, agent_soul = self._resolve_agent(app_model)
agent, agent_config_id, agent_soul = self._resolve_agent(
app_model,
invoke_from=invoke_from,
draft_type="draft",
user=user,
)
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=user
)
@@ -226,7 +244,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
call_depth=0,
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=snapshot.id,
agent_config_snapshot_id=agent_config_id,
)
conversation, message = self._init_generate_records(application_generate_entity, conversation)
@@ -421,50 +439,135 @@ class AgentAppGenerator(MessageBasedAppGenerator):
return False, query
def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
def _resolve_agent(
self,
app_model: App,
*,
invoke_from: InvokeFrom,
draft_type: Any,
user: Account | EndUser,
) -> tuple[Agent, str, AgentSoulConfig]:
agent = db.session.scalar(
select(Agent).where(
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.status == AgentStatus.ACTIVE,
or_(
and_(
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
),
Agent.backing_app_id == app_model.id,
),
)
.order_by(Agent.created_at.desc())
.limit(1)
)
if agent is None:
raise AgentAppGeneratorError("Agent App has no bound Agent")
return self._resolve_agent_by_id(
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
if invoke_from == InvokeFrom.DEBUGGER:
draft = self._resolve_debug_draft(
tenant_id=app_model.tenant_id,
agent=agent,
draft_type=draft_type,
account_id=user.id if isinstance(user, Account) else None,
)
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
return agent, draft.id, agent_soul
_, snapshot, agent_soul = self._resolve_agent_by_id(
tenant_id=app_model.tenant_id,
agent_id=agent.id,
snapshot_id=agent.active_config_snapshot_id,
)
return agent, snapshot.id, agent_soul
@staticmethod
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
"""Return the session scope snapshot id for Agent App runtime state.
Console preview/debug chat is an editing workspace: saving Agent Soul
creates replacement snapshots, but the user expects the same preview
conversation to keep context while trying prompt changes. Use a stable
NULL snapshot scope for debugger runs so each turn can use the latest
Agent Soul while reusing the conversation history. Published/web/API
runs keep snapshot-scoped sessions for reproducible runtime state.
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
immutable published snapshot ids. This keeps runtime session continuity
inside one editable surface without mixing draft/build/published state.
"""
if invoke_from == InvokeFrom.DEBUGGER:
return None
return snapshot_id
@staticmethod
def _resolve_debug_draft(
*, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None
) -> AgentConfigDraft:
effective_draft_type = (
AgentConfigDraftType.DEBUG_BUILD
if draft_type == AgentConfigDraftType.DEBUG_BUILD.value
else AgentConfigDraftType.DRAFT
)
stmt = select(AgentConfigDraft).where(
AgentConfigDraft.tenant_id == tenant_id,
AgentConfigDraft.agent_id == agent.id,
AgentConfigDraft.draft_type == effective_draft_type,
)
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
if not account_id:
raise AgentAppGeneratorError("Build draft requires an account user")
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
else:
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
draft = db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
if draft is not None:
return draft
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
raise AgentAppGeneratorError("Agent build draft not found")
_, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id(
tenant_id=tenant_id,
agent_id=agent.id,
snapshot_id=agent.active_config_snapshot_id,
)
draft = AgentConfigDraft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft_owner_key="",
base_snapshot_id=snapshot.id,
config_snapshot=agent_soul,
created_by=agent.created_by,
updated_by=agent.updated_by,
)
db.session.add(draft)
db.session.flush()
return draft
@staticmethod
def _resolve_agent_by_id(
*, tenant_id: str, agent_id: str, snapshot_id: str | None
) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
) -> tuple[Agent, AgentConfigSnapshot | AgentConfigDraft, AgentSoulConfig]:
agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id))
if agent is None:
raise AgentAppGeneratorError("Agent not found")
if not snapshot_id:
raise AgentAppGeneratorError("Agent has no published version")
snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id))
if snapshot is None:
snapshot = db.session.scalar(
select(AgentConfigSnapshot).where(
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent_id,
AgentConfigSnapshot.id == snapshot_id,
)
)
if snapshot is not None:
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return agent, snapshot, agent_soul
draft = db.session.scalar(
select(AgentConfigDraft).where(
AgentConfigDraft.tenant_id == tenant_id,
AgentConfigDraft.agent_id == agent_id,
AgentConfigDraft.id == snapshot_id,
)
)
if draft is None:
raise AgentAppGeneratorError("Agent published version not found")
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return agent, snapshot, agent_soul
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
return agent, draft, agent_soul
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
+114 -15
View File
@@ -22,7 +22,10 @@ from core.entities.provider_entities import (
SystemConfigurationStatus,
)
from core.helper import encrypter
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
from core.helper.model_provider_cache import (
ProviderCredentialsCache,
ProviderCredentialsCacheType,
)
from core.plugin.impl.model_runtime_factory import create_model_type_instance, create_plugin_model_assembly
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType
from graphon.model_runtime.entities.provider_entities import (
@@ -473,6 +476,39 @@ class ProviderConfiguration(BaseModel):
provider_names.append(model_provider_id.provider_name)
return provider_names
def _invalidate_provider_configuration_cache(
self,
*,
provider_models: bool = False,
preferred_model_providers: bool = False,
provider_model_settings: bool = False,
provider_model_credentials: bool = False,
provider_credentials: bool = False,
provider_load_balancing_configs: bool = False,
) -> None:
"""Invalidate tenant-scoped provider snapshots after committing configuration writes."""
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
sources: list[ProviderConfigurationCacheSource] = []
if provider_models:
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODELS)
if preferred_model_providers:
sources.append(ProviderConfigurationCacheSource.PREFERRED_MODEL_PROVIDERS)
if provider_model_settings:
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODEL_SETTINGS)
if provider_model_credentials:
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODEL_CREDENTIALS)
if provider_credentials:
sources.append(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS)
if provider_load_balancing_configs:
sources.append(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS)
if not sources:
logger.warning("No provider configuration cache source selected for invalidation")
return
ProviderManager.invalidate_configurations_cache(self.tenant_id, sources=sources)
def create_provider_credential(self, credentials: dict[str, Any], credential_name: str | None):
"""
Add custom provider credentials.
@@ -489,6 +525,7 @@ class ProviderConfiguration(BaseModel):
credentials = self.validate_provider_credentials(credentials=credentials)
preferred_model_providers_changed = False
with Session(db.engine) as session:
provider_record = self._get_provider_record(session)
try:
@@ -518,7 +555,9 @@ class ProviderConfiguration(BaseModel):
)
provider_model_credentials_cache.delete()
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
preferred_model_providers_changed = self.switch_preferred_provider_type(
provider_type=ProviderType.CUSTOM, session=session
)
else:
provider_record.is_valid = True
@@ -533,12 +572,18 @@ class ProviderConfiguration(BaseModel):
)
provider_model_credentials_cache.delete()
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
preferred_model_providers_changed = self.switch_preferred_provider_type(
provider_type=ProviderType.CUSTOM, session=session
)
session.commit()
except Exception:
session.rollback()
raise
self._invalidate_provider_configuration_cache(
preferred_model_providers=preferred_model_providers_changed,
provider_credentials=True,
)
def update_provider_credential(
self,
@@ -562,6 +607,7 @@ class ProviderConfiguration(BaseModel):
credentials = self.validate_provider_credentials(credentials=credentials, credential_id=credential_id)
load_balancing_configs_changed = False
with Session(db.engine) as session:
provider_record = self._get_provider_record(session)
stmt = select(ProviderCredential).where(
@@ -588,7 +634,7 @@ class ProviderConfiguration(BaseModel):
)
provider_model_credentials_cache.delete()
self._update_load_balancing_configs_with_credential(
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
credential_id=credential_id,
credential_record=credential_record,
credential_source=CredentialSourceType.PROVIDER,
@@ -597,6 +643,10 @@ class ProviderConfiguration(BaseModel):
except Exception:
session.rollback()
raise
self._invalidate_provider_configuration_cache(
provider_credentials=True,
provider_load_balancing_configs=load_balancing_configs_changed,
)
def _update_load_balancing_configs_with_credential(
self,
@@ -604,7 +654,7 @@ class ProviderConfiguration(BaseModel):
credential_record: ProviderCredential | ProviderModelCredential,
credential_source: str,
session: Session,
):
) -> bool:
"""
Update load balancing configurations that reference the given credential_id.
@@ -625,7 +675,7 @@ class ProviderConfiguration(BaseModel):
load_balancing_configs = session.execute(stmt).scalars().all()
if not load_balancing_configs:
return
return False
# Update each load balancing config with the new credentials
for lb_config in load_balancing_configs:
@@ -643,6 +693,7 @@ class ProviderConfiguration(BaseModel):
lb_credentials_cache.delete()
session.commit()
return True
def delete_provider_credential(self, credential_id: str):
"""
@@ -651,6 +702,8 @@ class ProviderConfiguration(BaseModel):
:param credential_id: credential id
:return:
"""
preferred_model_providers_changed = False
load_balancing_configs_changed = False
with Session(db.engine) as session:
stmt = select(ProviderCredential).where(
ProviderCredential.id == credential_id,
@@ -671,6 +724,7 @@ class ProviderConfiguration(BaseModel):
LoadBalancingModelConfig.credential_source_type == CredentialSourceType.PROVIDER,
)
lb_configs_using_credential = session.execute(lb_stmt).scalars().all()
load_balancing_configs_changed = bool(lb_configs_using_credential)
try:
for lb_config in lb_configs_using_credential:
lb_credentials_cache = ProviderCredentialsCache(
@@ -703,7 +757,9 @@ class ProviderConfiguration(BaseModel):
cache_type=ProviderCredentialsCacheType.PROVIDER,
)
provider_model_credentials_cache.delete()
self.switch_preferred_provider_type(provider_type=ProviderType.SYSTEM, session=session)
preferred_model_providers_changed = self.switch_preferred_provider_type(
provider_type=ProviderType.SYSTEM, session=session
)
elif provider_record and provider_record.credential_id == credential_id:
provider_record.credential_id = None
provider_record.updated_at = naive_utc_now()
@@ -714,12 +770,19 @@ class ProviderConfiguration(BaseModel):
cache_type=ProviderCredentialsCacheType.PROVIDER,
)
provider_model_credentials_cache.delete()
self.switch_preferred_provider_type(provider_type=ProviderType.SYSTEM, session=session)
preferred_model_providers_changed = self.switch_preferred_provider_type(
provider_type=ProviderType.SYSTEM, session=session
)
session.commit()
except Exception:
session.rollback()
raise
self._invalidate_provider_configuration_cache(
preferred_model_providers=preferred_model_providers_changed,
provider_credentials=True,
provider_load_balancing_configs=load_balancing_configs_changed,
)
def switch_active_provider_credential(self, credential_id: str):
"""
@@ -728,6 +791,7 @@ class ProviderConfiguration(BaseModel):
:param credential_id: credential id
:return:
"""
preferred_model_providers_changed = False
with Session(db.engine) as session:
stmt = select(ProviderCredential).where(
ProviderCredential.id == credential_id,
@@ -753,10 +817,14 @@ class ProviderConfiguration(BaseModel):
cache_type=ProviderCredentialsCacheType.PROVIDER,
)
provider_model_credentials_cache.delete()
self.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
preferred_model_providers_changed = self.switch_preferred_provider_type(
ProviderType.CUSTOM, session=session
)
except Exception:
session.rollback()
raise
if preferred_model_providers_changed:
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
def _get_custom_model_record(
self,
@@ -1017,6 +1085,10 @@ class ProviderConfiguration(BaseModel):
except Exception:
session.rollback()
raise
self._invalidate_provider_configuration_cache(
provider_models=True,
provider_model_credentials=True,
)
def update_custom_model_credential(
self,
@@ -1053,6 +1125,7 @@ class ProviderConfiguration(BaseModel):
credential_id=credential_id,
)
load_balancing_configs_changed = False
with Session(db.engine) as session:
provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session)
@@ -1082,7 +1155,7 @@ class ProviderConfiguration(BaseModel):
)
provider_model_credentials_cache.delete()
self._update_load_balancing_configs_with_credential(
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
credential_id=credential_id,
credential_record=credential_record,
credential_source=CredentialSourceType.CUSTOM_MODEL,
@@ -1091,6 +1164,11 @@ class ProviderConfiguration(BaseModel):
except Exception:
session.rollback()
raise
self._invalidate_provider_configuration_cache(
provider_models=True,
provider_model_credentials=True,
provider_load_balancing_configs=load_balancing_configs_changed,
)
def delete_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str):
"""
@@ -1099,6 +1177,7 @@ class ProviderConfiguration(BaseModel):
:param credential_id: credential id
:return:
"""
load_balancing_configs_changed = False
with Session(db.engine) as session:
stmt = select(ProviderModelCredential).where(
ProviderModelCredential.id == credential_id,
@@ -1118,6 +1197,7 @@ class ProviderConfiguration(BaseModel):
LoadBalancingModelConfig.credential_source_type == CredentialSourceType.CUSTOM_MODEL,
)
lb_configs_using_credential = session.execute(lb_stmt).scalars().all()
load_balancing_configs_changed = bool(lb_configs_using_credential)
try:
for lb_config in lb_configs_using_credential:
@@ -1161,6 +1241,11 @@ class ProviderConfiguration(BaseModel):
except Exception:
session.rollback()
raise
self._invalidate_provider_configuration_cache(
provider_models=True,
provider_model_credentials=True,
provider_load_balancing_configs=load_balancing_configs_changed,
)
def add_model_credential_to_model(self, model_type: ModelType, model: str, credential_id: str):
"""
@@ -1213,6 +1298,7 @@ class ProviderConfiguration(BaseModel):
session.add(provider_model_record)
session.commit()
self._invalidate_provider_configuration_cache(provider_models=True)
def switch_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str):
"""
@@ -1251,6 +1337,7 @@ class ProviderConfiguration(BaseModel):
cache_type=ProviderCredentialsCacheType.MODEL,
)
provider_model_credentials_cache.delete()
self._invalidate_provider_configuration_cache(provider_models=True)
def delete_custom_model(self, model_type: ModelType, model: str):
"""
@@ -1259,6 +1346,7 @@ class ProviderConfiguration(BaseModel):
:param model: model name
:return:
"""
provider_models_changed = False
with Session(db.engine) as session:
# get provider model
provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session)
@@ -1267,6 +1355,7 @@ class ProviderConfiguration(BaseModel):
if provider_model_record:
session.delete(provider_model_record)
session.commit()
provider_models_changed = True
provider_model_credentials_cache = ProviderCredentialsCache(
tenant_id=self.tenant_id,
@@ -1275,6 +1364,8 @@ class ProviderConfiguration(BaseModel):
)
provider_model_credentials_cache.delete()
if provider_models_changed:
self._invalidate_provider_configuration_cache(provider_models=True)
def _get_provider_model_setting(
self, model_type: ModelType, model: str, session: Session
@@ -1314,6 +1405,7 @@ class ProviderConfiguration(BaseModel):
)
session.add(model_setting)
session.commit()
self._invalidate_provider_configuration_cache(provider_model_settings=True)
return model_setting
@@ -1340,6 +1432,7 @@ class ProviderConfiguration(BaseModel):
)
session.add(model_setting)
session.commit()
self._invalidate_provider_configuration_cache(provider_model_settings=True)
return model_setting
@@ -1392,6 +1485,7 @@ class ProviderConfiguration(BaseModel):
)
session.add(model_setting)
session.commit()
self._invalidate_provider_configuration_cache(provider_model_settings=True)
return model_setting
@@ -1419,6 +1513,7 @@ class ProviderConfiguration(BaseModel):
)
session.add(model_setting)
session.commit()
self._invalidate_provider_configuration_cache(provider_model_settings=True)
return model_setting
@@ -1454,19 +1549,19 @@ class ProviderConfiguration(BaseModel):
credentials=credentials or {},
)
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None):
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None) -> bool:
"""
Switch preferred provider type.
:param provider_type:
:return:
"""
if provider_type == self.preferred_provider_type:
return
return False
if provider_type == ProviderType.SYSTEM and not self.system_configuration.enabled:
return
return False
def _switch(s: Session):
def _switch(s: Session) -> bool:
stmt = select(TenantPreferredModelProvider).where(
TenantPreferredModelProvider.tenant_id == self.tenant_id,
TenantPreferredModelProvider.provider_name.in_(self._get_provider_names()),
@@ -1483,12 +1578,16 @@ class ProviderConfiguration(BaseModel):
)
s.add(preferred_model_provider)
s.commit()
return True
if session:
return _switch(session)
else:
with Session(db.engine) as session:
return _switch(session)
changed = _switch(session)
if changed:
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
return changed
def extract_secret_variables(self, credential_form_schemas: list[CredentialFormSchema]) -> list[str]:
"""
+620 -55
View File
@@ -1,10 +1,14 @@
from __future__ import annotations
import contextlib
import json
import logging
from collections import defaultdict
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from dataclasses import asdict, dataclass
from enum import StrEnum
from json import JSONDecodeError
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Protocol, Self
from pydantic import TypeAdapter
from sqlalchemy import select
@@ -41,6 +45,7 @@ from graphon.model_runtime.entities.provider_entities import (
ProviderEntity,
)
from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
from models.enums import CredentialSourceType
from models.provider import (
LoadBalancingModelConfig,
Provider,
@@ -59,7 +64,494 @@ if TYPE_CHECKING:
from graphon.model_runtime.protocols.runtime import ModelRuntime
from models.account import Account
logger = logging.getLogger(__name__)
_credentials_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any])
_PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS = 300
_PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS = 360
_PROVIDER_CONFIGURATION_CACHE_VERSION_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:version"
_PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:v:{version}"
class ProviderConfigurationCacheSource(StrEnum):
PROVIDER_MODELS = "provider_models"
PREFERRED_MODEL_PROVIDERS = "preferred_model_providers"
PROVIDER_MODEL_SETTINGS = "provider_model_settings"
PROVIDER_MODEL_CREDENTIALS = "provider_model_credentials"
PROVIDER_CREDENTIALS = "provider_credentials"
PROVIDER_LOAD_BALANCING_CONFIGS = "provider_load_balancing_configs"
_PROVIDER_CONFIGURATION_SOURCES = tuple(ProviderConfigurationCacheSource)
class _CacheEntry(Protocol):
@classmethod
def from_cache_row(cls, row: dict[str, Any]) -> Self: ...
def to_cache_row(self) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class _ProviderConfigurationCacheSourceSpec[T: _CacheEntry]:
name: ProviderConfigurationCacheSource
entry_cls: type[T]
load_records: Callable[[str], list[T]]
@dataclass(frozen=True, slots=True)
class _ProviderModelCacheEntry:
id: str
provider_name: str
model_name: str
model_type: ModelType
credential_id: str | None
credential_name: str | None
encrypted_config: str | None
@classmethod
def from_record(cls, record: ProviderModel) -> _ProviderModelCacheEntry:
credential = record.__dict__.get("credential")
return cls(
id=record.id,
provider_name=record.provider_name,
model_name=record.model_name,
model_type=record.model_type,
credential_id=record.credential_id,
credential_name=credential.credential_name if credential else None,
encrypted_config=credential.encrypted_config if credential else None,
)
@classmethod
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelCacheEntry:
return cls(
id=row["id"],
provider_name=row["provider_name"],
model_name=row["model_name"],
model_type=ModelType(row["model_type"]),
credential_id=row.get("credential_id"),
credential_name=row.get("credential_name"),
encrypted_config=row.get("encrypted_config"),
)
def to_cache_row(self) -> dict[str, Any]:
row = asdict(self)
row["model_type"] = self.model_type.value
return row
@dataclass(frozen=True, slots=True)
class _TenantPreferredModelProviderCacheEntry:
provider_name: str
preferred_provider_type: ProviderType
@classmethod
def from_record(cls, record: TenantPreferredModelProvider) -> _TenantPreferredModelProviderCacheEntry:
return cls(
provider_name=record.provider_name,
preferred_provider_type=record.preferred_provider_type,
)
@classmethod
def from_cache_row(cls, row: dict[str, Any]) -> _TenantPreferredModelProviderCacheEntry:
return cls(
provider_name=row["provider_name"],
preferred_provider_type=ProviderType(row["preferred_provider_type"]),
)
def to_cache_row(self) -> dict[str, Any]:
return {
"provider_name": self.provider_name,
"preferred_provider_type": self.preferred_provider_type.value,
}
@dataclass(frozen=True, slots=True)
class _ProviderModelSettingCacheEntry:
provider_name: str
model_name: str
model_type: ModelType
enabled: bool
load_balancing_enabled: bool
@classmethod
def from_record(cls, record: ProviderModelSetting) -> _ProviderModelSettingCacheEntry:
return cls(
provider_name=record.provider_name,
model_name=record.model_name,
model_type=record.model_type,
enabled=record.enabled,
load_balancing_enabled=record.load_balancing_enabled,
)
@classmethod
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelSettingCacheEntry:
return cls(
provider_name=row["provider_name"],
model_name=row["model_name"],
model_type=ModelType(row["model_type"]),
enabled=row["enabled"],
load_balancing_enabled=row["load_balancing_enabled"],
)
def to_cache_row(self) -> dict[str, Any]:
return {
"provider_name": self.provider_name,
"model_name": self.model_name,
"model_type": self.model_type.value,
"enabled": self.enabled,
"load_balancing_enabled": self.load_balancing_enabled,
}
@dataclass(frozen=True, slots=True)
class _ProviderModelCredentialCacheEntry:
id: str
provider_name: str
model_name: str
model_type: ModelType
credential_name: str
@classmethod
def from_record(cls, record: ProviderModelCredential) -> _ProviderModelCredentialCacheEntry:
return cls(
id=record.id,
provider_name=record.provider_name,
model_name=record.model_name,
model_type=record.model_type,
credential_name=record.credential_name,
)
@classmethod
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelCredentialCacheEntry:
return cls(
id=row["id"],
provider_name=row["provider_name"],
model_name=row["model_name"],
model_type=ModelType(row["model_type"]),
credential_name=row["credential_name"],
)
def to_cache_row(self) -> dict[str, Any]:
return {
"id": self.id,
"provider_name": self.provider_name,
"model_name": self.model_name,
"model_type": self.model_type.value,
"credential_name": self.credential_name,
}
@dataclass(frozen=True, slots=True)
class _ProviderCredentialCacheEntry:
id: str
provider_name: str
credential_name: str
@classmethod
def from_record(cls, record: ProviderCredential) -> _ProviderCredentialCacheEntry:
return cls(
id=record.id,
provider_name=record.provider_name,
credential_name=record.credential_name,
)
@classmethod
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderCredentialCacheEntry:
return cls(
id=row["id"],
provider_name=row["provider_name"],
credential_name=row["credential_name"],
)
def to_cache_row(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True, slots=True)
class _LoadBalancingModelConfigCacheEntry:
id: str
tenant_id: str
provider_name: str
model_name: str
model_type: ModelType
name: str
encrypted_config: str | None
credential_id: str | None
credential_source_type: CredentialSourceType | None
enabled: bool
@classmethod
def from_record(cls, record: LoadBalancingModelConfig) -> _LoadBalancingModelConfigCacheEntry:
return cls(
id=record.id,
tenant_id=record.tenant_id,
provider_name=record.provider_name,
model_name=record.model_name,
model_type=record.model_type,
name=record.name,
encrypted_config=record.encrypted_config,
credential_id=record.credential_id,
credential_source_type=record.credential_source_type,
enabled=record.enabled,
)
@classmethod
def from_cache_row(cls, row: dict[str, Any]) -> _LoadBalancingModelConfigCacheEntry:
return cls(
id=row["id"],
tenant_id=row["tenant_id"],
provider_name=row["provider_name"],
model_name=row["model_name"],
model_type=ModelType(row["model_type"]),
name=row["name"],
encrypted_config=row.get("encrypted_config"),
credential_id=row.get("credential_id"),
credential_source_type=CredentialSourceType(row["credential_source_type"])
if row.get("credential_source_type")
else None,
enabled=row["enabled"],
)
def to_cache_row(self) -> dict[str, Any]:
return {
"id": self.id,
"tenant_id": self.tenant_id,
"provider_name": self.provider_name,
"model_name": self.model_name,
"model_type": self.model_type.value,
"name": self.name,
"encrypted_config": self.encrypted_config,
"credential_id": self.credential_id,
"credential_source_type": self.credential_source_type.value if self.credential_source_type else None,
"enabled": self.enabled,
}
class _ProviderConfigurationSourceCache:
"""Redis-backed cache for tenant provider DB cache entries.
The assembled ``ProviderConfigurations`` object is intentionally not cached
here because it carries request-scoped runtime bindings. Cache only the DB
rows that are stable enough to reuse across processes, then let each
``ProviderManager`` assemble and bind fresh runtime-aware entities.
"""
@classmethod
def get_records[T: _CacheEntry](
cls,
*,
tenant_id: str,
source: ProviderConfigurationCacheSource,
entry_cls: type[T],
) -> tuple[list[T] | None, str | None]:
version: str | None = None
try:
version = cls._get_version(tenant_id=tenant_id, source=source)
cache_key = cls._source_key(tenant_id=tenant_id, source=source, version=version)
cached_records = redis_client.get(cache_key)
if cached_records is None:
return None, version
cached_text = cached_records.decode("utf-8") if isinstance(cached_records, bytes) else cached_records
rows = json.loads(cached_text)
if not isinstance(rows, list):
return None, version
return [entry_cls.from_cache_row(row) for row in rows if isinstance(row, dict)], version
except Exception:
logger.warning("Failed to read provider configuration source cache", exc_info=True)
return None, version
@classmethod
def set_records(
cls,
*,
tenant_id: str,
source: ProviderConfigurationCacheSource,
records: Sequence[_CacheEntry],
expected_version: str | None = None,
) -> None:
try:
version = cls._get_version(tenant_id=tenant_id, source=source)
if expected_version is not None and version != expected_version:
return
cache_key = cls._source_key(tenant_id=tenant_id, source=source, version=version)
rows = [record.to_cache_row() for record in records]
redis_client.setex(cache_key, _PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS, json.dumps(rows))
except Exception:
logger.warning("Failed to write provider configuration source cache", exc_info=True)
@classmethod
def invalidate_tenant(
cls,
tenant_id: str,
sources: Sequence[ProviderConfigurationCacheSource] | None = None,
) -> None:
try:
if sources is None:
sources = _PROVIDER_CONFIGURATION_SOURCES
for source in sources:
version_key = _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY.format(tenant_id=tenant_id, source=source.value)
redis_client.incr(version_key)
redis_client.expire(version_key, _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
except Exception:
logger.warning("Failed to invalidate provider configuration source cache", exc_info=True)
@classmethod
def _get_version(cls, *, tenant_id: str, source: ProviderConfigurationCacheSource) -> str:
version_key = _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY.format(tenant_id=tenant_id, source=source.value)
version = redis_client.get(version_key)
if version is None:
redis_client.set(version_key, "0", ex=_PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
return "0"
redis_client.expire(version_key, _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
return version.decode("utf-8") if isinstance(version, bytes) else str(version)
@staticmethod
def _source_key(*, tenant_id: str, source: ProviderConfigurationCacheSource, version: str) -> str:
return _PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY.format(
tenant_id=tenant_id,
source=source.value,
version=version,
)
def _get_cached_or_load_records[T: _CacheEntry](
*,
tenant_id: str,
cache_source: _ProviderConfigurationCacheSourceSpec[T],
) -> list[T]:
cached_records, cache_version = _ProviderConfigurationSourceCache.get_records(
tenant_id=tenant_id,
source=cache_source.name,
entry_cls=cache_source.entry_cls,
)
if cached_records is not None:
return cached_records
records = cache_source.load_records(tenant_id)
_ProviderConfigurationSourceCache.set_records(
tenant_id=tenant_id,
source=cache_source.name,
records=records,
expected_version=cache_version,
)
return records
def _attach_active_credentials(
*,
session: Any,
records: Sequence[Provider | ProviderModel],
credential_model_cls: type[ProviderCredential | ProviderModelCredential],
) -> None:
credential_ids = [record.credential_id for record in records if getattr(record, "credential_id", None)]
if not credential_ids:
return
credentials = session.scalars(select(credential_model_cls).where(credential_model_cls.id.in_(credential_ids))).all()
credential_by_id = {credential.id: credential for credential in credentials}
for record in records:
if getattr(record, "credential_id", None):
record.__dict__["credential"] = credential_by_id.get(record.credential_id)
def _load_provider_model_cache_entries(tenant_id: str) -> list[_ProviderModelCacheEntry]:
with session_factory.create_session() as session:
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
provider_models = list(session.scalars(stmt))
_attach_active_credentials(
session=session,
records=provider_models,
credential_model_cls=ProviderModelCredential,
)
return [_ProviderModelCacheEntry.from_record(provider_model) for provider_model in provider_models]
def _load_preferred_model_provider_cache_entries(tenant_id: str) -> list[_TenantPreferredModelProviderCacheEntry]:
with session_factory.create_session() as session:
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
return [
_TenantPreferredModelProviderCacheEntry.from_record(preferred_model_provider)
for preferred_model_provider in session.scalars(stmt)
]
def _load_provider_model_setting_cache_entries(tenant_id: str) -> list[_ProviderModelSettingCacheEntry]:
with session_factory.create_session() as session:
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
return [
_ProviderModelSettingCacheEntry.from_record(provider_model_setting)
for provider_model_setting in session.scalars(stmt)
]
def _load_provider_model_credential_cache_entries(tenant_id: str) -> list[_ProviderModelCredentialCacheEntry]:
with session_factory.create_session() as session:
stmt = (
select(ProviderModelCredential)
.where(ProviderModelCredential.tenant_id == tenant_id)
.order_by(ProviderModelCredential.created_at.desc())
)
return [
_ProviderModelCredentialCacheEntry.from_record(provider_model_credential)
for provider_model_credential in session.scalars(stmt)
]
def _load_provider_credential_cache_entries(tenant_id: str) -> list[_ProviderCredentialCacheEntry]:
with session_factory.create_session() as session:
stmt = (
select(ProviderCredential)
.where(ProviderCredential.tenant_id == tenant_id)
.order_by(ProviderCredential.created_at.desc())
)
return [
_ProviderCredentialCacheEntry.from_record(provider_credential)
for provider_credential in session.scalars(stmt)
]
def _load_provider_load_balancing_config_cache_entries(tenant_id: str) -> list[_LoadBalancingModelConfigCacheEntry]:
with session_factory.create_session() as session:
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
return [
_LoadBalancingModelConfigCacheEntry.from_record(load_balancing_model_config)
for load_balancing_model_config in session.scalars(stmt)
]
_PROVIDER_MODELS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
name=ProviderConfigurationCacheSource.PROVIDER_MODELS,
entry_cls=_ProviderModelCacheEntry,
load_records=_load_provider_model_cache_entries,
)
_PREFERRED_MODEL_PROVIDERS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
name=ProviderConfigurationCacheSource.PREFERRED_MODEL_PROVIDERS,
entry_cls=_TenantPreferredModelProviderCacheEntry,
load_records=_load_preferred_model_provider_cache_entries,
)
_PROVIDER_MODEL_SETTINGS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
name=ProviderConfigurationCacheSource.PROVIDER_MODEL_SETTINGS,
entry_cls=_ProviderModelSettingCacheEntry,
load_records=_load_provider_model_setting_cache_entries,
)
_PROVIDER_MODEL_CREDENTIALS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
name=ProviderConfigurationCacheSource.PROVIDER_MODEL_CREDENTIALS,
entry_cls=_ProviderModelCredentialCacheEntry,
load_records=_load_provider_model_credential_cache_entries,
)
_PROVIDER_CREDENTIALS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
name=ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,
entry_cls=_ProviderCredentialCacheEntry,
load_records=_load_provider_credential_cache_entries,
)
_PROVIDER_LOAD_BALANCING_CONFIGS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
name=ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,
entry_cls=_LoadBalancingModelConfigCacheEntry,
load_records=_load_provider_load_balancing_config_cache_entries,
)
class ProviderManager:
@@ -98,6 +590,14 @@ class ProviderManager:
self._configurations_cache.pop(tenant_id, None)
@staticmethod
def invalidate_configurations_cache(
tenant_id: str,
sources: Sequence[ProviderConfigurationCacheSource] | None = None,
) -> None:
"""Invalidate cross-process provider configuration source cache for a tenant."""
_ProviderConfigurationSourceCache.invalidate_tenant(tenant_id, sources=sources)
def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
"""
Get model provider configurations.
@@ -192,6 +692,9 @@ class ProviderManager:
# Get All provider model credentials
provider_name_to_provider_model_credentials_dict = self._get_all_provider_model_credentials(tenant_id)
# Get All provider credentials
provider_name_to_provider_credentials_dict = self._get_all_provider_credentials(tenant_id)
provider_configurations = ProviderConfigurations(tenant_id=tenant_id)
# Construct ProviderConfiguration objects for each provider
@@ -224,7 +727,12 @@ class ProviderManager:
# Convert to custom configuration
custom_configuration = self._to_custom_configuration(
tenant_id, provider_entity, provider_records, provider_model_records, provider_model_credentials
tenant_id,
provider_entity,
provider_records,
provider_model_records,
provider_model_credentials,
provider_name_to_provider_credentials_dict,
)
# Convert to system configuration
@@ -448,84 +956,115 @@ class ProviderManager:
provider_name_to_provider_records_dict = defaultdict(list)
with session_factory.create_session() as session:
stmt = select(Provider).where(Provider.tenant_id == tenant_id, Provider.is_valid == True)
providers = session.scalars(stmt)
providers = list(session.scalars(stmt))
_attach_active_credentials(
session=session,
records=providers,
credential_model_cls=ProviderCredential,
)
for provider in providers:
# Use provider name with prefix after the data migration
provider_name_to_provider_records_dict[str(ModelProviderID(provider.provider_name))].append(provider)
return provider_name_to_provider_records_dict
@staticmethod
def _get_all_provider_models(tenant_id: str) -> dict[str, list[ProviderModel]]:
def _get_all_provider_models(tenant_id: str) -> dict[str, list[_ProviderModelCacheEntry]]:
"""
Get all provider model records of the workspace.
:param tenant_id: workspace id
:return:
"""
provider_models = _get_cached_or_load_records(
tenant_id=tenant_id,
cache_source=_PROVIDER_MODELS_CACHE_SOURCE,
)
provider_name_to_provider_model_records_dict = defaultdict(list)
with session_factory.create_session() as session:
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
provider_models = session.scalars(stmt)
for provider_model in provider_models:
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
for provider_model in provider_models:
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
return provider_name_to_provider_model_records_dict
@staticmethod
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, TenantPreferredModelProvider]:
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, _TenantPreferredModelProviderCacheEntry]:
"""
Get All preferred provider types of the workspace.
:param tenant_id: workspace id
:return:
"""
provider_name_to_preferred_provider_type_records_dict = {}
with session_factory.create_session() as session:
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
preferred_provider_types = session.scalars(stmt)
provider_name_to_preferred_provider_type_records_dict = {
preferred_provider_type.provider_name: preferred_provider_type
for preferred_provider_type in preferred_provider_types
}
return provider_name_to_preferred_provider_type_records_dict
preferred_provider_types = _get_cached_or_load_records(
tenant_id=tenant_id,
cache_source=_PREFERRED_MODEL_PROVIDERS_CACHE_SOURCE,
)
return {
preferred_provider_type.provider_name: preferred_provider_type
for preferred_provider_type in preferred_provider_types
}
@staticmethod
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[ProviderModelSetting]]:
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[_ProviderModelSettingCacheEntry]]:
"""
Get All provider model settings of the workspace.
:param tenant_id: workspace id
:return:
"""
provider_model_settings = _get_cached_or_load_records(
tenant_id=tenant_id,
cache_source=_PROVIDER_MODEL_SETTINGS_CACHE_SOURCE,
)
provider_name_to_provider_model_settings_dict = defaultdict(list)
with session_factory.create_session() as session:
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
provider_model_settings = session.scalars(stmt)
for provider_model_setting in provider_model_settings:
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
provider_model_setting
)
for provider_model_setting in provider_model_settings:
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
provider_model_setting
)
return provider_name_to_provider_model_settings_dict
@staticmethod
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[ProviderModelCredential]]:
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[_ProviderModelCredentialCacheEntry]]:
"""
Get All provider model credentials of the workspace.
:param tenant_id: workspace id
:return:
"""
provider_model_credentials = _get_cached_or_load_records(
tenant_id=tenant_id,
cache_source=_PROVIDER_MODEL_CREDENTIALS_CACHE_SOURCE,
)
provider_name_to_provider_model_credentials_dict = defaultdict(list)
with session_factory.create_session() as session:
stmt = select(ProviderModelCredential).where(ProviderModelCredential.tenant_id == tenant_id)
provider_model_credentials = session.scalars(stmt)
for provider_model_credential in provider_model_credentials:
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
provider_model_credential
)
for provider_model_credential in provider_model_credentials:
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
provider_model_credential
)
return provider_name_to_provider_model_credentials_dict
@staticmethod
def _get_all_provider_load_balancing_configs(tenant_id: str) -> dict[str, list[LoadBalancingModelConfig]]:
def _get_all_provider_credentials(tenant_id: str) -> dict[str, list[_ProviderCredentialCacheEntry]]:
"""
Get All provider credentials of the workspace.
:param tenant_id: workspace id
:return:
"""
provider_credentials = _get_cached_or_load_records(
tenant_id=tenant_id,
cache_source=_PROVIDER_CREDENTIALS_CACHE_SOURCE,
)
provider_name_to_provider_credentials_dict = defaultdict(list)
for provider_credential in provider_credentials:
provider_name_to_provider_credentials_dict[provider_credential.provider_name].append(provider_credential)
return provider_name_to_provider_credentials_dict
@staticmethod
def _get_all_provider_load_balancing_configs(
tenant_id: str,
) -> dict[str, list[_LoadBalancingModelConfigCacheEntry]]:
"""
Get All provider load balancing configs of the workspace.
@@ -546,14 +1085,16 @@ class ProviderManager:
if not model_load_balancing_enabled:
return {}
provider_load_balancing_configs = _get_cached_or_load_records(
tenant_id=tenant_id,
cache_source=_PROVIDER_LOAD_BALANCING_CONFIGS_CACHE_SOURCE,
)
provider_name_to_provider_load_balancing_model_configs_dict = defaultdict(list)
with session_factory.create_session() as session:
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
provider_load_balancing_configs = session.scalars(stmt)
for provider_load_balancing_config in provider_load_balancing_configs:
provider_name_to_provider_load_balancing_model_configs_dict[
provider_load_balancing_config.provider_name
].append(provider_load_balancing_config)
for provider_load_balancing_config in provider_load_balancing_configs:
provider_name_to_provider_load_balancing_model_configs_dict[
provider_load_balancing_config.provider_name
].append(provider_load_balancing_config)
return provider_name_to_provider_load_balancing_model_configs_dict
@@ -722,8 +1263,9 @@ class ProviderManager:
tenant_id: str,
provider_entity: ProviderEntity,
provider_records: list[Provider],
provider_model_records: list[ProviderModel],
provider_model_credentials: list[ProviderModelCredential],
provider_model_records: list[_ProviderModelCacheEntry],
provider_model_credentials: list[_ProviderModelCredentialCacheEntry],
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
) -> CustomConfiguration:
"""
Convert to custom configuration.
@@ -736,7 +1278,10 @@ class ProviderManager:
"""
# Get custom provider configuration
custom_provider_configuration = self._get_custom_provider_configuration(
tenant_id, provider_entity, provider_records
tenant_id,
provider_entity,
provider_records,
provider_credentials_by_name,
)
# Get custom models which have not been added to the model list yet
@@ -758,7 +1303,11 @@ class ProviderManager:
)
def _get_custom_provider_configuration(
self, tenant_id: str, provider_entity: ProviderEntity, provider_records: list[Provider]
self,
tenant_id: str,
provider_entity: ProviderEntity,
provider_records: list[Provider],
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
) -> CustomProviderConfiguration | None:
"""Get custom provider configuration."""
# Find custom provider record (non-system)
@@ -790,13 +1339,29 @@ class ProviderManager:
credentials=provider_credentials,
current_credential_name=custom_provider_record.credential_name,
current_credential_id=custom_provider_record.credential_id,
available_credentials=self.get_provider_available_credentials(
tenant_id, custom_provider_record.provider_name
available_credentials=self._get_provider_available_credentials_from_records(
custom_provider_record.provider_name,
provider_credentials_by_name,
),
)
@staticmethod
def _get_provider_available_credentials_from_records(
provider_name: str,
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
) -> list[CredentialConfiguration]:
available_credentials: list[CredentialConfiguration] = []
for candidate_provider_name in ProviderManager._get_provider_names(provider_name):
available_credentials.extend(
CredentialConfiguration(credential_id=credential.id, credential_name=credential.credential_name)
for credential in provider_credentials_by_name.get(candidate_provider_name, [])
)
return available_credentials
def _get_can_added_models(
self, provider_model_records: list[ProviderModel], all_model_credentials: Sequence[ProviderModelCredential]
self,
provider_model_records: list[_ProviderModelCacheEntry],
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
) -> list[dict]:
"""Get the custom models and credentials from enterprise version which haven't add to the model list"""
existing_model_set = {(record.model_name, record.model_type) for record in provider_model_records}
@@ -829,9 +1394,9 @@ class ProviderManager:
self,
tenant_id: str,
provider_entity: ProviderEntity,
provider_model_records: list[ProviderModel],
provider_model_records: list[_ProviderModelCacheEntry],
can_added_models: list[dict],
all_model_credentials: Sequence[ProviderModelCredential],
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
) -> list[CustomModelConfiguration]:
"""Get custom model configurations."""
# Get model credential secret variables
@@ -1151,8 +1716,8 @@ class ProviderManager:
def _to_model_settings(
self,
provider_entity: ProviderEntity,
provider_model_settings: list[ProviderModelSetting] | None = None,
load_balancing_model_configs: list[LoadBalancingModelConfig] | None = None,
provider_model_settings: list[_ProviderModelSettingCacheEntry] | None = None,
load_balancing_model_configs: list[_LoadBalancingModelConfigCacheEntry] | None = None,
) -> list[ModelSettings]:
"""
Convert to model settings.
+18 -2
View File
@@ -31,6 +31,7 @@ from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, Wo
from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent
from graphon.nodes.base.node import Node
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from services.agent.prompt_mentions import extract_workflow_node_output_selectors
from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason
from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
@@ -688,5 +689,20 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
node_id: str,
node_data: DifyAgentNodeData,
) -> Mapping[str, Sequence[str]]:
del graph_config, node_id, node_data
return {}
"""Reuse frontend workflow-marker parsing for graph variable loading.
This follows the same marker parser used by publish sync and runtime
request building, including reserved-prefix exclusion.
"""
del graph_config
agent_task = (
node_data.get("agent_task") if isinstance(node_data, Mapping) else getattr(node_data, "agent_task", None)
)
if not isinstance(agent_task, str):
return {}
return {
f"{node_id}.{'.'.join(selector)}": list(selector)
for selector in extract_workflow_node_output_selectors(agent_task)
}
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any
from models.agent_config_entities import AgentSoulConfig
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
{
@@ -48,9 +49,7 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
)
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
reserved_status["knowledge"] = (
"supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured"
)
reserved_status["knowledge"] = "supported_by_knowledge_layer" if agent_soul.knowledge.sets else "not_configured"
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
reserved_status["env"] = "supported_by_shell_bootstrap"
@@ -66,14 +65,14 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
def list_configured_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
"""Return the normalized knowledge dataset ids that can produce a runtime layer.
"""Return normalized dataset ids selected by Agent v2 knowledge sets.
``build_runtime_feature_manifest()`` and ``build_knowledge_layer_config()``
must stay aligned: both decide knowledge support from this effective,
non-blank dataset-id set rather than from raw
``agent_soul.knowledge.datasets`` entries.
stay aligned on the set-based contract: DTO validation rejects blank dataset
ids before runtime, so this helper only flattens configured set datasets for
metadata/diagnostic surfaces that still need a dataset-id summary.
"""
return [dataset_id for dataset in agent_soul.knowledge.datasets if (dataset_id := (dataset.id or "").strip())]
return list_agent_soul_knowledge_dataset_ids(agent_soul)
def _get_nested(value: dict[str, Any], path: str) -> Any:
@@ -1,10 +1,12 @@
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Literal, Protocol, assert_never, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.agent_stub.protocol import AgentStubFileMapping
from dify_agent.layers.ask_human import DifyAskHumanLayerConfig
from dify_agent.layers.drive import (
DifyDriveLayerConfig,
@@ -15,7 +17,16 @@ from dify_agent.layers.execution_context import (
DifyExecutionContextLayerConfig,
DifyExecutionContextUserFrom,
)
from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig, DifyKnowledgeRetrievalConfig
from dify_agent.layers.knowledge import (
DifyKnowledgeBaseLayerConfig,
DifyKnowledgeDatasetConfig,
DifyKnowledgeMetadataFilteringConfig,
DifyKnowledgeModelConfig,
DifyKnowledgeQueryConfig,
DifyKnowledgeRerankingModelConfig,
DifyKnowledgeRetrievalConfig,
DifyKnowledgeSetConfig,
)
from dify_agent.layers.shell import (
DifyShellCliToolConfig,
DifyShellEnvVarConfig,
@@ -24,7 +35,7 @@ from dify_agent.layers.shell import (
DifyShellSecretRefConfig,
)
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from clients.agent_backend import (
AgentBackendModelConfig,
@@ -36,11 +47,13 @@ from clients.agent_backend import (
from configs import dify_config
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.file import FileTransferMethod
from graphon.file import File, FileTransferMethod
from graphon.variables.segments import Segment
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentKnowledgeQueryConfig,
AgentKnowledgeMetadataFilteringConfig,
AgentKnowledgeModelConfig,
AgentKnowledgeRetrievalConfig,
AgentSoulConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
@@ -58,13 +71,16 @@ from services.agent.prompt_mentions import (
build_node_job_mention_resolver,
build_soul_mention_resolver,
expand_prompt_mentions,
extract_workflow_node_output_selectors,
normalize_previous_node_output_selector,
parse_prompt_mentions,
workflow_previous_node_output_refs_from_selectors,
)
from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref
from .output_failure_orchestrator import retry_idempotency_key
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids
from .runtime_feature_manifest import build_runtime_feature_manifest
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation")
@@ -74,6 +90,13 @@ _DANGEROUS_ACK_KEYS = (
"risk_accepted",
"approved",
)
type AgentStubFileTransferMethod = Literal["local_file", "tool_file", "datasource_file", "remote_url"]
_AGENT_STUB_FILE_TRANSFER_METHODS: Mapping[FileTransferMethod, AgentStubFileTransferMethod] = {
FileTransferMethod.LOCAL_FILE: "local_file",
FileTransferMethod.TOOL_FILE: "tool_file",
FileTransferMethod.DATASOURCE_FILE: "datasource_file",
FileTransferMethod.REMOTE_URL: "remote_url",
}
class WorkflowAgentRuntimeRequestBuildError(ValueError):
@@ -126,6 +149,9 @@ class WorkflowAgentRuntimeRequest:
class WorkflowAgentRuntimeRequestBuilder:
"""Build public Dify Agent run requests from workflow Agent v2 runtime state."""
_WORKFLOW_USER_PROMPT_FALLBACK = "Use the current workflow context."
_WORKFLOW_JOB_PROMPT_FALLBACK = "Use the workflow user prompt for this run."
def __init__(
self,
*,
@@ -147,15 +173,13 @@ class WorkflowAgentRuntimeRequestBuilder:
)
metadata = self._build_metadata(context, agent_soul, node_job)
workflow_context_prompt = self._build_workflow_context_prompt(context, node_job)
# ENG-616: expand slash-menu mention tokens into model-readable names.
# node_output mentions expand to their reference name only — the value
# stays in the Workflow context block (user_prompt) below.
workflow_job_prompt = (
expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
or "Run this workflow Agent Node for the current run."
effective_node_job = node_job.model_copy(
update={"previous_node_output_refs": self._effective_previous_node_output_refs(node_job)}
)
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
workflow_task_prompt = self._build_workflow_task_prompt(context, effective_node_job)
workflow_context_prompt = self._build_workflow_context_prompt(context, effective_node_job)
workflow_job_prompt = workflow_task_prompt or self._WORKFLOW_JOB_PROMPT_FALLBACK
user_prompt = workflow_context_prompt or self._WORKFLOW_USER_PROMPT_FALLBACK
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
tools_layer = self._plugin_tools_builder.build(
@@ -310,15 +334,19 @@ class WorkflowAgentRuntimeRequestBuilder:
context: WorkflowAgentRuntimeBuildContext,
node_job: WorkflowNodeJobConfig,
) -> str:
lines = ["Workflow context loaded for this run:"]
lines: list[str] = []
query = get_system_text(context.variable_pool, SystemVariableKey.QUERY)
if query:
lines.append(f"- User query: {query}")
resolved_outputs = self._resolve_previous_node_outputs(
context.variable_pool,
node_job.previous_node_output_refs,
)
if not query and not resolved_outputs:
return ""
lines.append("Workflow context loaded for this run:")
if query:
lines.append(f"- User query: {query}")
if resolved_outputs:
lines.append("- Previous node outputs:")
for item in resolved_outputs:
@@ -327,6 +355,31 @@ class WorkflowAgentRuntimeRequestBuilder:
lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.")
return "\n".join(lines)
def _build_workflow_task_prompt(
self,
context: WorkflowAgentRuntimeBuildContext,
node_job: WorkflowNodeJobConfig,
) -> str:
del context
return expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
def _effective_previous_node_output_refs(
self,
node_job: WorkflowNodeJobConfig,
) -> list[WorkflowPreviousNodeOutputRef]:
"""Derive effective refs from the current frontend task markers.
The task text is the source of truth for previous-node context. When
the prompt has no frontend markers, stale persisted refs are discarded
instead of being carried forward into workflow context.
"""
if not node_job.workflow_prompt:
return list(node_job.previous_node_output_refs)
return workflow_previous_node_output_refs_from_selectors(
extract_workflow_node_output_selectors(node_job.workflow_prompt)
)
def _resolve_previous_node_outputs(
self,
variable_pool: VariablePoolReader,
@@ -334,7 +387,7 @@ class WorkflowAgentRuntimeRequestBuilder:
) -> list[dict[str, Any]]:
resolved: list[dict[str, Any]] = []
for ref in refs:
selector = self._selector_from_ref(ref)
selector = normalize_previous_node_output_selector(ref)
if not selector:
raise WorkflowAgentRuntimeRequestBuildError(
"invalid_previous_node_output_ref",
@@ -355,25 +408,73 @@ class WorkflowAgentRuntimeRequestBuilder:
)
return resolved
@staticmethod
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> list[str] | None:
for key in ("selector", "variable_selector", "value_selector"):
value = ref.get(key)
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return value
node_id = ref.get("node_id")
output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key")
if isinstance(node_id, str) and isinstance(output_name, str):
return [node_id, output_name]
return None
@staticmethod
def _summarize_value(value: Any) -> str:
prompt_payload, used_download_mapping = WorkflowAgentRuntimeRequestBuilder._resolve_prompt_payload_value(value)
if used_download_mapping:
return json.dumps(prompt_payload, ensure_ascii=False, separators=(",", ":"))
text = str(value)
if len(text) > 2000:
return text[:2000] + "...[truncated]"
return text
@classmethod
def _resolve_prompt_payload_value(cls, value: Any) -> tuple[Any, bool]:
# File-valued workflow context must surface as Agent Stub download
# mappings so the model can materialize those inputs with
# `dify-agent file download --mapping ...` inside the sandbox.
download_mapping = cls._agent_stub_download_mapping(value)
if download_mapping is not None:
return download_mapping, True
if isinstance(value, list | tuple):
changed = False
resolved_items: list[Any] = []
for item in value:
resolved_item, item_changed = cls._resolve_prompt_payload_value(item)
resolved_items.append(resolved_item)
changed = changed or item_changed
return resolved_items, changed
if isinstance(value, Mapping):
changed = False
resolved_items_by_key: dict[str, Any] = {}
for key, item in value.items():
resolved_item, item_changed = cls._resolve_prompt_payload_value(item)
resolved_items_by_key[str(key)] = resolved_item
changed = changed or item_changed
return resolved_items_by_key, changed
return value, False
@staticmethod
def _agent_stub_download_mapping(value: Any) -> dict[str, Any] | None:
try:
if isinstance(value, File):
transfer_method = _AGENT_STUB_FILE_TRANSFER_METHODS.get(value.transfer_method)
if transfer_method is None:
return None
if value.transfer_method == FileTransferMethod.REMOTE_URL:
url = value.remote_url
if not isinstance(url, str) or not url:
return None
mapping = AgentStubFileMapping(transfer_method=transfer_method, url=url)
else:
reference = value.reference
if not isinstance(reference, str) or not reference:
return None
mapping = AgentStubFileMapping(transfer_method=transfer_method, reference=reference)
return mapping.model_dump(mode="json", exclude_none=True)
if isinstance(value, Mapping):
mapping = AgentStubFileMapping.model_validate(value)
return mapping.model_dump(mode="json", exclude_none=True)
except ValidationError:
return None
return None
@staticmethod
def _build_output_config(declared_outputs: Sequence[DeclaredOutputConfig]) -> AgentBackendOutputConfig | None:
"""Build the structured-output layer config sent to Agent backend.
@@ -547,42 +648,84 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None:
"""Map Agent Soul knowledge config into the fixed Dify knowledge-base layer.
"""Map Agent Soul knowledge sets into one Dify knowledge-base layer.
Normalization intentionally matches the current dify-agent runtime contract:
- blank or missing dataset ids are ignored;
- if no valid dataset ids remain, no knowledge layer is injected;
- retrieval mode is always forced to ``multiple`` in this first wiring pass;
- ``top_k`` falls back to a stable runtime default when the soul omits it;
- ``score_threshold`` is only forwarded when the product config explicitly
enables it, otherwise the layer keeps the disabled/default ``0.0`` value;
- metadata filtering stays at the layer DTO default (disabled).
Agent Soul DTO validation owns malformed set rejection. Runtime mapping is
intentionally lossless: every configured set is forwarded with its query
policy, dataset refs, retrieval controls, and metadata-filtering controls.
``score_threshold=None`` means disabled threshold filtering and maps to the
inner retrieval request's ``0.0`` default through the Agent backend DTO.
"""
dataset_ids = list_configured_knowledge_dataset_ids(agent_soul)
if not dataset_ids:
if not agent_soul.knowledge.sets:
return None
query_config = agent_soul.knowledge.query_config
return DifyKnowledgeBaseLayerConfig(
dataset_ids=dataset_ids,
retrieval=DifyKnowledgeRetrievalConfig(
mode="multiple",
top_k=_knowledge_top_k(query_config),
score_threshold=_knowledge_score_threshold(query_config),
),
sets=[
DifyKnowledgeSetConfig(
id=knowledge_set.id,
name=knowledge_set.name,
description=knowledge_set.description,
datasets=[
DifyKnowledgeDatasetConfig(
id=dataset.id or "",
name=dataset.name,
description=dataset.description,
)
for dataset in knowledge_set.datasets
],
query=DifyKnowledgeQueryConfig(
mode=cast(Literal["user_query", "generated_query"], knowledge_set.query.mode.value),
value=knowledge_set.query.value,
),
retrieval=_knowledge_retrieval_config(knowledge_set.retrieval),
metadata_filtering=_knowledge_metadata_filtering_config(knowledge_set.metadata_filtering),
)
for knowledge_set in agent_soul.knowledge.sets
],
)
def _knowledge_top_k(query_config: AgentKnowledgeQueryConfig) -> int:
top_k = query_config.top_k
return top_k if isinstance(top_k, int) and top_k >= 1 else 4
def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> DifyKnowledgeRetrievalConfig:
return DifyKnowledgeRetrievalConfig(
mode=retrieval.mode,
top_k=retrieval.top_k,
score_threshold=retrieval.score_threshold or 0.0,
reranking_mode=retrieval.reranking_mode,
reranking_enable=retrieval.reranking_enable,
reranking_model=DifyKnowledgeRerankingModelConfig(
provider=retrieval.reranking_model.provider,
model=retrieval.reranking_model.model,
)
if retrieval.reranking_model is not None
else None,
weights=cast(dict[str, Any], retrieval.weights.model_dump(mode="json", exclude_none=True))
if retrieval.weights is not None
else None,
model=_knowledge_model_config(retrieval.model),
)
def _knowledge_score_threshold(query_config: AgentKnowledgeQueryConfig) -> float:
if query_config.score_threshold_enabled and query_config.score_threshold is not None:
return query_config.score_threshold
return 0.0
def _knowledge_metadata_filtering_config(
metadata_filtering: AgentKnowledgeMetadataFilteringConfig,
) -> DifyKnowledgeMetadataFilteringConfig:
return DifyKnowledgeMetadataFilteringConfig(
mode=metadata_filtering.mode,
model_config=_knowledge_model_config(metadata_filtering.metadata_model_config),
conditions=cast(Any, metadata_filtering.conditions.model_dump(mode="json"))
if metadata_filtering.conditions is not None
else None,
)
def _knowledge_model_config(model: AgentKnowledgeModelConfig | None) -> DifyKnowledgeModelConfig | None:
if model is None:
return None
return DifyKnowledgeModelConfig(
provider=model.provider,
name=model.name,
mode=model.mode,
completion_params=model.completion_params,
)
def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None:
@@ -18,6 +18,7 @@ from models.agent_config_entities import (
)
from models.model import UploadFile
from models.workflow import Workflow
from services.agent.knowledge_datasets import list_missing_tenant_knowledge_dataset_ids
from .entities import DifyAgentNodeData
@@ -146,6 +147,7 @@ class WorkflowAgentNodeValidator:
)
cls._validate_agent_soul_env(binding=binding, agent_soul=agent_soul)
cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul)
cls._validate_agent_soul_knowledge(binding=binding, agent_soul=agent_soul)
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
@@ -364,6 +366,24 @@ class WorkflowAgentNodeValidator:
)
cli_tool_names.add(normalized_name)
@classmethod
def _validate_agent_soul_knowledge(
cls,
*,
binding: WorkflowAgentNodeBinding,
agent_soul: AgentSoulConfig,
) -> None:
"""Validate knowledge set dataset rows against the publishing tenant."""
missing_ids = list_missing_tenant_knowledge_dataset_ids(
tenant_id=binding.tenant_id,
agent_soul=agent_soul,
)
if missing_ids:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references missing or out-of-scope knowledge datasets: "
f"{', '.join(missing_ids)}."
)
@classmethod
def _validate_agent_soul_env(
cls,
+47 -2
View File
@@ -6,6 +6,7 @@ from pydantic import Field, field_validator
from fields.base import ResponseModel
from libs.helper import to_timestamp
from models.agent import (
AgentConfigDraftType,
AgentConfigRevisionOperation,
AgentIconType,
AgentKind,
@@ -47,6 +48,18 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel):
created_at: int | None = None
class AgentConfigDraftSummaryResponse(ResponseModel):
id: str
agent_id: str
draft_type: AgentConfigDraftType
account_id: str | None = None
base_snapshot_id: str | None = None
created_by: str | None = None
updated_by: str | None = None
created_at: int | None = None
updated_at: int | None = None
class AgentPublishedReferenceResponse(ResponseModel):
app_id: str
app_name: str
@@ -72,6 +85,8 @@ class AgentRosterResponse(ResponseModel):
scope: AgentScope
source: AgentSource
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
workflow_id: str | None = None
workflow_node_id: str | None = None
active_config_snapshot_id: str | None = None
@@ -292,14 +307,24 @@ class AgentConfigSnapshotListResponse(ResponseModel):
class AgentConfigSnapshotRestoreResponse(ResponseModel):
result: Literal["success"]
active_config_snapshot_id: str
draft_config_id: str | None = None
restored_version_id: str | None = None
class AgentComposerAgentResponse(ResponseModel):
id: str
name: str
description: str
role: str | None = None
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
scope: AgentScope
source: AgentSource | None = None
status: AgentStatus
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
active_config_snapshot_id: str | None = None
@@ -343,6 +368,9 @@ class WorkflowAgentComposerResponse(ResponseModel):
impact_summary: AgentComposerImpactResponse | None = None
validation: "ComposerValidationFindingsResponse | None" = None
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
chat_endpoint: str | None = None
workflow_id: str | None = None
node_id: str | None = None
@@ -350,10 +378,15 @@ class WorkflowAgentComposerResponse(ResponseModel):
class AgentAppComposerResponse(ResponseModel):
variant: Literal[ComposerVariant.AGENT_APP]
agent: AgentComposerAgentResponse
active_config_snapshot: AgentConfigSnapshotSummaryResponse
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
draft: AgentConfigDraftSummaryResponse | None = None
agent_soul: AgentSoulConfig
save_options: list[ComposerSaveStrategy]
validation: "ComposerValidationFindingsResponse | None" = None
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
chat_endpoint: str | None = None
class ComposerValidationWarningResponse(ResponseModel):
@@ -400,10 +433,22 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
class AgentComposerKnowledgeDatasetCandidateResponse(AgentKnowledgeDatasetConfig):
missing: bool = False
class AgentComposerKnowledgeSetCandidateResponse(ResponseModel):
id: str
name: str
description: str | None = None
datasets: list[AgentComposerKnowledgeDatasetCandidateResponse] = Field(default_factory=list)
missing_dataset_ids: list[str] = Field(default_factory=list)
class AgentComposerSoulCandidatesResponse(ResponseModel):
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
knowledge_sets: list[AgentComposerKnowledgeSetCandidateResponse] = Field(default_factory=list)
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
@@ -1,16 +1,5 @@
"""add workflow_version to workflow_agent_node_bindings
Restores the stage 1 §5.3 unique key
``(tenant_id, workflow_id, workflow_version, node_id)`` so draft and published
workflow bindings can coexist at the same workflow_id once we want to track
them per workflow version. ``workflow_version`` mirrors ``workflows.version``
("draft" or a published version string).
Because the New Agent Experience feature is pre-release, this table is empty
in every environment that matters; the ``server_default='draft'`` only exists
to keep developer-local rows valid during the alter and is dropped immediately
afterward so application code must specify ``workflow_version`` explicitly.
Revision ID: 97e2e1a644e8
Revises: f8b6b7e9c421
Create Date: 2026-05-25 11:43:37.611300
@@ -33,10 +22,8 @@ def upgrade():
'workflow_version',
sa.String(length=255),
nullable=False,
server_default='draft',
)
)
batch_op.alter_column('workflow_version', server_default=None)
batch_op.drop_constraint(
batch_op.f('workflow_agent_node_binding_node_unique'), type_='unique'
)
@@ -18,8 +18,7 @@ depends_on = None
def upgrade():
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False, server_default=""))
batch_op.alter_column("role", server_default=None)
batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False))
def downgrade():
@@ -6,15 +6,9 @@ Create Date: 2026-06-18 23:00:00.000000
"""
from __future__ import annotations
import json
from typing import Any
from alembic import op
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
from sqlalchemy.engine.mock import MockConnection
# revision identifiers, used by Alembic.
revision = "b2515f9d4c2a"
@@ -37,46 +31,9 @@ def upgrade() -> None:
"agent_drive_files",
["tenant_id", "agent_id", "is_skill", "key"],
)
_remove_skills_files_from_snapshots()
def downgrade() -> None:
op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files")
op.drop_column("agent_drive_files", "skill_metadata")
op.drop_column("agent_drive_files", "is_skill")
def _remove_skills_files_from_snapshots() -> None:
connection = op.get_bind()
if connection is None or isinstance(connection, MockConnection):
return
snapshots = sa.table(
"agent_config_snapshots",
sa.column("id", sa.String()),
sa.column("config_snapshot", sa.Text()),
)
rows = connection.execute(sa.select(snapshots.c.id, snapshots.c.config_snapshot)).fetchall()
for row in rows:
cleaned = _strip_skills_files(row.config_snapshot)
if cleaned is None:
continue
connection.execute(
snapshots.update()
.where(snapshots.c.id == row.id)
.values(config_snapshot=json.dumps(cleaned, separators=(",", ":"), sort_keys=True))
)
def _strip_skills_files(raw_snapshot: Any) -> dict[str, Any] | None:
if raw_snapshot is None:
return None
if isinstance(raw_snapshot, str):
snapshot = json.loads(raw_snapshot)
elif isinstance(raw_snapshot, dict):
snapshot = dict(raw_snapshot)
else:
snapshot = dict(raw_snapshot)
if not isinstance(snapshot, dict) or "skills_files" not in snapshot:
return None
snapshot.pop("skills_files", None)
return snapshot
@@ -0,0 +1,26 @@
"""add input placeholder to sites
Revision ID: a6f1c9d2e8b4
Revises: d9e8f7a6b5c4
Create Date: 2026-06-24 19:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "a6f1c9d2e8b4"
down_revision = "d9e8f7a6b5c4"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("sites", schema=None) as batch_op:
batch_op.add_column(sa.Column("input_placeholder", sa.String(length=255), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("sites", schema=None) as batch_op:
batch_op.drop_column("input_placeholder")
@@ -0,0 +1,56 @@
"""add agent config drafts
Revision ID: e4f5a6b7c8d9
Revises: a6f1c9d2e8b4
Create Date: 2026-06-24 20:15:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "e4f5a6b7c8d9"
down_revision = "a6f1c9d2e8b4"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"agent_config_drafts",
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("draft_type", sa.String(length=32), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=True),
sa.Column("draft_owner_key", sa.String(length=255), nullable=False),
sa.Column("base_snapshot_id", models.types.StringUUID(), nullable=True),
sa.Column("config_snapshot", models.types.LongText(), nullable=False),
sa.Column("created_by", models.types.StringUUID(), nullable=True),
sa.Column("updated_by", models.types.StringUUID(), nullable=True),
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_config_draft_pkey")),
sa.UniqueConstraint(
"tenant_id",
"agent_id",
"draft_type",
"draft_owner_key",
name=op.f("agent_config_draft_agent_type_account_unique"),
),
)
op.create_index("agent_config_draft_tenant_agent_idx", "agent_config_drafts", ["tenant_id", "agent_id"])
op.create_index(
"agent_config_draft_base_snapshot_idx",
"agent_config_drafts",
["tenant_id", "base_snapshot_id"],
)
def downgrade():
op.drop_index("agent_config_draft_base_snapshot_idx", table_name="agent_config_drafts")
op.drop_index("agent_config_draft_tenant_agent_idx", table_name="agent_config_drafts")
op.drop_table("agent_config_drafts")
@@ -0,0 +1,30 @@
"""add agent backing app id
Revision ID: a2b3c4d5e6f7
Revises: e4f5a6b7c8d9
Create Date: 2026-06-25 11:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "a2b3c4d5e6f7"
down_revision = "e4f5a6b7c8d9"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.add_column(sa.Column("backing_app_id", models.types.StringUUID(), nullable=True))
op.create_index("agent_tenant_backing_app_id_idx", "agents", ["tenant_id", "backing_app_id"])
def downgrade():
op.drop_index("agent_tenant_backing_app_id_idx", table_name="agents")
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.drop_column("backing_app_id")
@@ -0,0 +1,37 @@
"""add agent active config is published
Revision ID: c3d4e5f6a7b8
Revises: a2b3c4d5e6f7
Create Date: 2026-06-26 10:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "c3d4e5f6a7b8"
down_revision = "a2b3c4d5e6f7"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"active_config_is_published",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
comment=(
"Whether the normal shared Agent draft has been published into the active config snapshot. "
"User-scoped debug drafts do not affect this flag."
),
)
)
def downgrade():
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.drop_column("active_config_is_published")
+4
View File
@@ -10,6 +10,8 @@ from .account import (
)
from .agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@@ -154,6 +156,8 @@ __all__ = [
"AccountStatus",
"AccountTrialAppRecord",
"Agent",
"AgentConfigDraft",
"AgentConfigDraftType",
"AgentConfigRevision",
"AgentConfigRevisionOperation",
"AgentConfigSnapshot",
+71 -3
View File
@@ -85,6 +85,17 @@ class AgentConfigRevisionOperation(StrEnum):
SAVE_TO_ROSTER = "save_to_roster"
# Switches the Agent's current published config back to an existing version.
RESTORE_VERSION = "restore_version"
# Publishes the editable Agent Soul draft as a new immutable version.
PUBLISH_DRAFT = "publish_draft"
class AgentConfigDraftType(StrEnum):
"""Editable Agent Soul draft workspace type."""
# Shared Agent Console draft edited by users before publishing.
DRAFT = "draft"
# Per-editor build draft mutated during debug/build mode.
DEBUG_BUILD = "debug_build"
class WorkflowAgentBindingType(StrEnum):
@@ -134,6 +145,7 @@ class Agent(DefaultFieldsMixin, Base):
Index("agent_tenant_scope_idx", "tenant_id", "scope"),
Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"),
Index("agent_tenant_app_id_idx", "tenant_id", "app_id"),
Index("agent_tenant_backing_app_id_idx", "tenant_id", "backing_app_id"),
Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"),
Index(
"agent_tenant_invitable_idx",
@@ -162,12 +174,30 @@ class Agent(DefaultFieldsMixin, Base):
scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False)
source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False)
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
backing_app_id: Mapped[str | None] = mapped_column(
StringUUID,
nullable=True,
comment=(
"Runtime Agent App used for chat/log/monitoring. For workflow-only agents, "
"app_id remains the parent workflow app id and this points to the hidden backing app."
),
)
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
active_config_has_model: Mapped[bool] = mapped_column(
sa.Boolean, nullable=False, default=False, server_default=sa.text("false")
)
active_config_is_published: Mapped[bool] = mapped_column(
sa.Boolean,
nullable=False,
default=False,
server_default=sa.text("false"),
comment=(
"Whether the normal shared Agent draft has been published into the active config snapshot. "
"User-scoped debug drafts do not affect this flag."
),
)
status: Mapped[AgentStatus] = mapped_column(
EnumText(AgentStatus, length=32), nullable=False, default=AgentStatus.ACTIVE
)
@@ -210,6 +240,44 @@ class AgentDebugConversation(DefaultFieldsMixin, Base):
conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
class AgentConfigDraft(DefaultFieldsMixin, Base):
"""Editable Agent Soul draft separated from immutable published snapshots."""
__tablename__ = "agent_config_drafts"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_config_draft_pkey"),
UniqueConstraint(
"tenant_id",
"agent_id",
"draft_type",
"draft_owner_key",
name="agent_config_draft_agent_type_account_unique",
),
Index("agent_config_draft_tenant_agent_idx", "tenant_id", "agent_id"),
Index("agent_config_draft_base_snapshot_idx", "tenant_id", "base_snapshot_id"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
draft_type: Mapped[AgentConfigDraftType] = mapped_column(EnumText(AgentConfigDraftType, length=32), nullable=False)
account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="")
base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
@property
def config_snapshot_dict(self) -> dict[str, Any]:
if not self.config_snapshot:
return {}
if hasattr(self.config_snapshot, "model_dump"):
return self.config_snapshot.model_dump(mode="json")
if isinstance(self.config_snapshot, str):
return json.loads(self.config_snapshot)
return dict(self.config_snapshot)
class AgentConfigSnapshot(DefaultFieldsMixin, Base):
"""Immutable Agent Soul snapshot.
@@ -355,9 +423,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base):
agent_config_snapshot_id / composition_layer_specs`` columns are set.
- Agent App conversations: ``owner_type = conversation``; the
``conversation_id`` column is set and the workflow columns stay NULL.
Published/web/API runs scope runtime state by ``agent_config_snapshot_id``;
console debugger runs may keep it NULL so prompt-only draft saves can reuse
the same preview conversation state while executing the latest Agent Soul.
Runtime state is scoped by ``agent_config_snapshot_id``. For published
web/API runs this points to an immutable AgentConfigSnapshot; for console
debugger/build runs it points to the editable AgentConfigDraft row.
The snapshot is runtime state returned by Agent backend, kept separate from
Agent Soul snapshots and workflow node-job config.
+178 -8
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
import re
from enum import StrEnum
from typing import Annotated, Any, Final, Literal
from typing import Annotated, Any, Final, Literal, Self
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator
from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator
from core.workflow.file_reference import is_canonical_file_reference
from graphon.file import FileTransferMethod
@@ -161,6 +162,11 @@ class AgentSkillRefConfig(AgentFlexibleConfig):
manifest_files: list[str] | None = None
class AgentSoulFilesConfig(BaseModel):
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
files: list[AgentFileRefConfig] = Field(default_factory=list)
class AgentPermissionConfig(BaseModel):
model_config = ConfigDict(extra="ignore")
@@ -236,17 +242,161 @@ class AgentCliToolConfig(AgentFlexibleConfig):
inferred_from: str | None = Field(default=None, max_length=255)
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):
class AgentKnowledgeDatasetConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str | None = Field(default=None, max_length=255)
name: str | None = Field(default=None, max_length=255)
description: str | None = None
class AgentKnowledgeQueryConfig(AgentFlexibleConfig):
query: str | None = None
class AgentKnowledgeQueryConfig(BaseModel):
"""Per-set query policy for Agent v2 knowledge retrieval.
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
set owns its own query policy, so ``user_query`` must carry an explicit
``value`` while ``generated_query`` leaves that value empty.
"""
model_config = ConfigDict(extra="forbid")
mode: AgentKnowledgeQueryMode
value: str | None = None
@model_validator(mode="after")
def validate_query(self) -> Self:
if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip():
raise ValueError("knowledge query.value is required for user_query mode")
return self
class AgentKnowledgeModelConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
provider: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=255)
mode: str = Field(min_length=1, max_length=64)
completion_params: dict[str, Any] = Field(default_factory=dict)
class AgentKnowledgeRerankingModelConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
provider: str = Field(min_length=1, max_length=255)
model: str = Field(min_length=1, max_length=255)
class AgentKnowledgeWeightedScoreConfig(AgentFlexibleConfig):
weight_type: str | None = Field(default=None, max_length=64)
vector_setting: dict[str, Any] | None = None
keyword_setting: dict[str, Any] | None = None
class AgentKnowledgeRetrievalConfig(BaseModel):
"""Per-set retrieval policy for Agent v2 knowledge retrieval.
Retrieval settings now live on each knowledge set instead of one shared
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
``single`` retrieval with a required model config.
"""
model_config = ConfigDict(extra="forbid")
mode: Literal["single", "multiple"]
top_k: int | None = Field(default=None, ge=1)
score_threshold: float | None = Field(default=None, ge=0, le=1)
score_threshold_enabled: bool | None = None
reranking_mode: str = "reranking_model"
reranking_enable: bool = True
reranking_model: AgentKnowledgeRerankingModelConfig | None = None
weights: AgentKnowledgeWeightedScoreConfig | None = None
model: AgentKnowledgeModelConfig | None = None
@model_validator(mode="after")
def validate_mode_fields(self) -> Self:
if self.mode == "multiple" and self.top_k is None:
raise ValueError("knowledge retrieval.top_k is required for multiple mode")
if self.mode == "single" and self.model is None:
raise ValueError("knowledge retrieval.model is required for single mode")
return self
class AgentKnowledgeMetadataCondition(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
comparison_operator: SupportedComparisonOperator
value: ConditionValue = None
class AgentKnowledgeMetadataConditions(BaseModel):
model_config = ConfigDict(extra="forbid")
logical_operator: Literal["and", "or"] = "and"
conditions: list[AgentKnowledgeMetadataCondition] = Field(default_factory=list)
class AgentKnowledgeMetadataFilteringConfig(BaseModel):
"""Per-set metadata filtering policy.
The Python attribute uses ``metadata_model_config`` for clarity because the
model belongs to metadata filtering specifically, while the external API and
generated schema keep the historical ``model_config`` field name via alias.
"""
model_config = ConfigDict(extra="forbid", populate_by_name=True)
mode: Literal["disabled", "automatic", "manual"] = "disabled"
# Internal name is explicit; wire format remains ``model_config``.
metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config")
conditions: AgentKnowledgeMetadataConditions | None = None
@model_validator(mode="after")
def validate_mode_fields(self) -> Self:
if self.mode == "automatic" and self.metadata_model_config is None:
raise ValueError("metadata_filtering.model_config is required for automatic mode")
if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions):
raise ValueError("metadata_filtering.conditions is required for manual mode")
return self
class AgentKnowledgeSetConfig(BaseModel):
"""One explicit knowledge set in Agent v2.
``knowledge.sets`` replaces the old flat knowledge config. Each set owns
its datasets plus query, retrieval, and metadata policies. An individual
set must contain at least one dataset id even though the overall knowledge
section may be empty, which is how callers express "no knowledge layer".
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
datasets: list[AgentKnowledgeDatasetConfig]
query: AgentKnowledgeQueryConfig
retrieval: AgentKnowledgeRetrievalConfig
metadata_filtering: AgentKnowledgeMetadataFilteringConfig = Field(
default_factory=AgentKnowledgeMetadataFilteringConfig
)
@field_validator("id", "name")
@classmethod
def validate_non_blank_identity(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("knowledge set id and name must not be blank")
return normalized
@model_validator(mode="after")
def validate_datasets(self) -> Self:
dataset_ids = [(dataset.id or "").strip() for dataset in self.datasets]
if not dataset_ids or any(not dataset_id for dataset_id in dataset_ids):
raise ValueError("knowledge set requires at least one dataset id")
if len(dataset_ids) != len(set(dataset_ids)):
raise ValueError("knowledge set dataset ids must be unique")
return self
class AgentHumanContactConfig(AgentFlexibleConfig):
@@ -453,9 +603,28 @@ class AgentSoulToolsConfig(BaseModel):
class AgentSoulKnowledgeConfig(BaseModel):
datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
query_mode: AgentKnowledgeQueryMode | None = None
query_config: AgentKnowledgeQueryConfig = Field(default_factory=AgentKnowledgeQueryConfig)
"""Top-level Agent v2 knowledge config.
Agent v2 models knowledge as explicit sets instead of one flat
``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
list means no knowledge layer should be emitted at runtime, while set-name
uniqueness stays case-insensitive because runtime selection addresses sets
by name.
"""
model_config = ConfigDict(extra="forbid")
sets: list[AgentKnowledgeSetConfig] = Field(default_factory=list)
@model_validator(mode="after")
def validate_unique_sets(self) -> Self:
set_ids = [item.id.strip() for item in self.sets]
if len(set_ids) != len(set(set_ids)):
raise ValueError("knowledge set ids must be unique")
set_names = [item.name.strip().lower() for item in self.sets]
if len(set_names) != len(set(set_names)):
raise ValueError("knowledge set names must be unique")
return self
class AgentSoulHumanConfig(BaseModel):
@@ -513,6 +682,7 @@ class AgentSoulConfig(BaseModel):
knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig)
human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig)
env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig)
files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
model: AgentSoulModelConfig | None = None
+10 -3
View File
@@ -487,9 +487,15 @@ class App(Base):
agent = db.session.scalar(
select(Agent).where(
Agent.app_id == self.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.tenant_id == self.tenant_id,
sa.or_(
sa.and_(
Agent.app_id == self.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
),
Agent.backing_app_id == self.id,
),
Agent.status == AgentStatus.ACTIVE,
)
)
@@ -2183,6 +2189,7 @@ class Site(Base):
chat_color_theme_inverted: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
copyright = mapped_column(String(255))
privacy_policy = mapped_column(String(255))
input_placeholder = mapped_column(String(255))
show_workflow_steps: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"))
use_icon_as_answer_icon: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
_custom_disclaimer: Mapped[str] = mapped_column("custom_disclaimer", LongText, default="")
+384 -12
View File
@@ -465,6 +465,83 @@ Check if activation token is valid
| ---- | ----------- |
| 204 | Agent service API key deleted |
### [DELETE] /agent/{agent_id}/build-draft
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent build draft discarded | **application/json**: [AgentSimpleResultResponse](#agentsimpleresultresponse)<br> |
### [GET] /agent/{agent_id}/build-draft
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent build draft | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
### [PUT] /agent/{agent_id}/build-draft
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent build draft saved | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
### [POST] /agent/{agent_id}/build-draft/apply
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent build draft applied | **application/json**: [AgentBuildDraftApplyResponse](#agentbuilddraftapplyresponse)<br> |
### [POST] /agent/{agent_id}/build-draft/checkout
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentBuildDraftCheckoutPayload](#agentbuilddraftcheckoutpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent build draft checked out | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
### [GET] /agent/{agent_id}/chat-messages
Get Agent App chat messages for a conversation with pagination
@@ -856,6 +933,26 @@ Get Agent App message details by ID
| 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)<br> |
| 404 | Agent or message not found | |
### [POST] /agent/{agent_id}/publish
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentPublishPayload](#agentpublishpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent draft published | **application/json**: [AgentPublishResponse](#agentpublishresponse)<br> |
| 403 | Insufficient permissions | |
### [GET] /agent/{agent_id}/referencing-workflows
List workflow apps that reference this Agent App's bound Agent (read-only)
@@ -3767,6 +3864,7 @@ Submit human input form preview for workflow
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| snapshot_id | query | | No | string |
| app_id | path | | Yes | string (uuid) |
| node_id | path | | Yes | string |
@@ -12173,9 +12271,14 @@ Default namespace
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| app_id | string | | No |
| backing_app_id | string | | No |
| chat_endpoint | string | | No |
| draft | [AgentConfigDraftSummaryResponse](#agentconfigdraftsummaryresponse) | | No |
| hidden_app_backed | boolean | | No |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
@@ -12210,6 +12313,7 @@ Default namespace
| active_config_is_published | boolean | | No |
| api_base_url | string | | No |
| app_id | string | | No |
| backing_app_id | string | | No |
| bound_agent_id | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
@@ -12218,6 +12322,7 @@ Default namespace
| description | string | | No |
| enable_api | boolean | | Yes |
| enable_site | boolean | | Yes |
| hidden_app_backed | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
@@ -12230,7 +12335,7 @@ Default namespace
| name | string | | Yes |
| permission_keys | [ string ] | | No |
| role | string | | No |
| site | [Site](#site) | | No |
| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No |
| tags | [ [Tag](#tag) ] | | No |
| tracing | [JSONValue](#jsonvalue) | | No |
| updated_at | integer | | No |
@@ -12273,6 +12378,7 @@ default (the config form sends the full desired feature state on save).
| active_config_is_published | boolean | | No |
| app_id | string | | No |
| author_name | string | | No |
| backing_app_id | string | | No |
| bound_agent_id | string | | No |
| create_user_name | string | | No |
| created_at | integer | | No |
@@ -12280,6 +12386,7 @@ default (the config form sends the full desired feature state on save).
| debug_conversation_id | string | | No |
| description | string | | No |
| has_draft_trigger | boolean | | No |
| hidden_app_backed | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
@@ -12338,6 +12445,27 @@ default (the config form sends the full desired feature state on save).
| date | string | | Yes |
| interactions | number | | Yes |
#### AgentBuildDraftApplyResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| draft | object | | Yes |
| result | string | | Yes |
#### AgentBuildDraftCheckoutPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| force | boolean | Overwrite the existing current-user build draft | No |
#### AgentBuildDraftResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_soul | object | | Yes |
| draft | object | | Yes |
| variant | string | | Yes |
#### AgentCliToolAuthorizationStatus
Authorization state for Agent-scoped CLI tools.
@@ -12400,10 +12528,18 @@ Risk marker for CLI tool bootstrap commands.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot_id | string | | No |
| app_id | string | | No |
| backing_app_id | string | | No |
| description | string | | Yes |
| hidden_app_backed | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| id | string | | Yes |
| name | string | | Yes |
| role | string | | No |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | No |
| status | [AgentStatus](#agentstatus) | | Yes |
#### AgentComposerBindingResponse
@@ -12456,6 +12592,25 @@ Risk marker for CLI tool bootstrap commands.
| current_snapshot_id | string | | No |
| workflow_node_count | integer | | Yes |
#### AgentComposerKnowledgeDatasetCandidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| id | string | | No |
| missing | boolean | | No |
| name | string | | No |
#### AgentComposerKnowledgeSetCandidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| datasets | [ [AgentComposerKnowledgeDatasetCandidateResponse](#agentcomposerknowledgedatasetcandidateresponse) ] | | No |
| description | string | | No |
| id | string | | Yes |
| missing_dataset_ids | [ string ] | | No |
| name | string | | Yes |
#### AgentComposerNodeJobCandidatesResponse
| Name | Type | Description | Required |
@@ -12471,7 +12626,7 @@ Risk marker for CLI tool bootstrap commands.
| cli_tools | [ [AgentCliToolConfig](#agentclitoolconfig) ] | | No |
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
| knowledge_sets | [ [AgentComposerKnowledgeSetCandidateResponse](#agentcomposerknowledgesetcandidateresponse) ] | | No |
#### AgentComposerSoulLockResponse
@@ -12490,6 +12645,28 @@ Risk marker for CLI tool bootstrap commands.
| result | string | | Yes |
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
#### AgentConfigDraftSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| account_id | string | | No |
| agent_id | string | | Yes |
| base_snapshot_id | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | | Yes |
| id | string | | Yes |
| updated_at | integer | | No |
| updated_by | string | | No |
#### AgentConfigDraftType
Editable Agent Soul draft workspace type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentConfigDraftType | string | Editable Agent Soul draft workspace type. | |
#### AgentConfigRevisionOperation
Audit operation recorded for Agent Soul version/revision changes.
@@ -12539,6 +12716,8 @@ Audit operation recorded for Agent Soul version/revision changes.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot_id | string | | Yes |
| draft_config_id | string | | No |
| restored_version_id | string | | No |
| result | string | | Yes |
#### AgentConfigSnapshotSummaryResponse
@@ -12793,10 +12972,12 @@ Supported icon storage formats for Agent roster entries.
| app_id | string | | No |
| archived_at | integer | | No |
| archived_by | string | | No |
| backing_app_id | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| description | string | | Yes |
| existing_node_ids | [ string ] | | No |
| hidden_app_backed | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
@@ -12865,14 +13046,57 @@ the current roster/workflow APIs scoped to Dify Agent.
| id | string | | No |
| name | string | | No |
#### AgentKnowledgeQueryConfig
#### AgentKnowledgeMetadataCondition
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| query | string | | No |
| score_threshold | number | | No |
| score_threshold_enabled | boolean | | No |
| top_k | integer | | No |
| comparison_operator | string, <br>**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes |
| name | string | | Yes |
| value | string<br>[ string ]<br>number | | No |
#### AgentKnowledgeMetadataConditions
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conditions | [ [AgentKnowledgeMetadataCondition](#agentknowledgemetadatacondition) ] | | No |
| logical_operator | string, <br>**Available values:** "and", "or", <br>**Default:** and | *Enum:* `"and"`, `"or"` | No |
#### AgentKnowledgeMetadataFilteringConfig
Per-set metadata filtering policy.
The Python attribute uses ``metadata_model_config`` for clarity because the
model belongs to metadata filtering specifically, while the external API and
generated schema keep the historical ``model_config`` field name via alias.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conditions | [AgentKnowledgeMetadataConditions](#agentknowledgemetadataconditions) | | No |
| mode | string, <br>**Available values:** "automatic", "disabled", "manual", <br>**Default:** disabled | *Enum:* `"automatic"`, `"disabled"`, `"manual"` | No |
| model_config | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
#### AgentKnowledgeModelConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| completion_params | object | | No |
| mode | string | | Yes |
| name | string | | Yes |
| provider | string | | Yes |
#### AgentKnowledgeQueryConfig
Per-set query policy for Agent v2 knowledge retrieval.
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
set owns its own query policy, so ``user_query`` must carry an explicit
``value`` while ``generated_query`` leaves that value empty.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | Yes |
| value | string | | No |
#### AgentKnowledgeQueryMode
@@ -12880,6 +13104,59 @@ the current roster/workflow APIs scoped to Dify Agent.
| ---- | ---- | ----------- | -------- |
| AgentKnowledgeQueryMode | string | | |
#### AgentKnowledgeRerankingModelConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| model | string | | Yes |
| provider | string | | Yes |
#### AgentKnowledgeRetrievalConfig
Per-set retrieval policy for Agent v2 knowledge retrieval.
Retrieval settings now live on each knowledge set instead of one shared
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
``single`` retrieval with a required model config.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mode | string, <br>**Available values:** "multiple", "single" | *Enum:* `"multiple"`, `"single"` | Yes |
| model | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
| reranking_enable | boolean, <br>**Default:** true | | No |
| reranking_mode | string, <br>**Default:** reranking_model | | No |
| reranking_model | [AgentKnowledgeRerankingModelConfig](#agentknowledgererankingmodelconfig) | | No |
| score_threshold | number | | No |
| top_k | integer | | No |
| weights | [AgentKnowledgeWeightedScoreConfig](#agentknowledgeweightedscoreconfig) | | No |
#### AgentKnowledgeSetConfig
One explicit knowledge set in Agent v2.
``knowledge.sets`` replaces the old flat knowledge config. Each set owns
its datasets plus query, retrieval, and metadata policies. An individual
set must contain at least one dataset id even though the overall knowledge
section may be empty, which is how callers express "no knowledge layer".
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | Yes |
| description | string | | No |
| id | string | | Yes |
| metadata_filtering | [AgentKnowledgeMetadataFilteringConfig](#agentknowledgemetadatafilteringconfig) | | No |
| name | string | | Yes |
| query | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | Yes |
| retrieval | [AgentKnowledgeRetrievalConfig](#agentknowledgeretrievalconfig) | | Yes |
#### AgentKnowledgeWeightedScoreConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword_setting | object | | No |
| vector_setting | object | | No |
| weight_type | string | | No |
#### AgentLogConversationItemResponse
| Name | Type | Description | Required |
@@ -13063,6 +13340,21 @@ the current roster/workflow APIs scoped to Dify Agent.
| ---- | ---- | ----------- | -------- |
| AgentProviderResponse | object | | |
#### AgentPublishPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| version_note | string | Optional note for this published Agent version | No |
#### AgentPublishResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | object | | No |
| active_config_snapshot_id | string | | Yes |
| draft | object | | No |
| result | string | | Yes |
#### AgentPublishedReferenceResponse
| Name | Type | Description | Required |
@@ -13120,9 +13412,11 @@ the current roster/workflow APIs scoped to Dify Agent.
| app_id | string | | No |
| archived_at | integer | | No |
| archived_by | string | | No |
| backing_app_id | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| description | string | | Yes |
| hidden_app_backed | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
@@ -13190,6 +13484,27 @@ Visibility and lifecycle scope of an Agent record.
| enabled | boolean | | No |
| type | string | | No |
#### AgentSimpleResultResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### AgentSkillRefConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| file_id | string | | No |
| full_archive_file_id | string | | No |
| full_archive_key | string | | No |
| id | string | | No |
| manifest_files | [ string ] | | No |
| name | string | | No |
| path | string | | No |
| skill_md_file_id | string | | No |
| skill_md_key | string | | No |
#### AgentSkillUploadResponse
| Name | Type | Description | Required |
@@ -13216,6 +13531,7 @@ Visibility and lifecycle scope of an Agent record.
| app_features | [AgentSoulAppFeaturesConfig](#agentsoulappfeaturesconfig) | | No |
| app_variables | [ [AppVariableConfig](#appvariableconfig) ] | | No |
| env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No |
| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No |
| human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No |
| knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No |
| memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No |
@@ -13270,6 +13586,13 @@ old Agent tool payloads can be read while new payloads stay explicit.
| secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No |
| variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No |
#### AgentSoulFilesConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No |
| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
#### AgentSoulHumanConfig
| Name | Type | Description | Required |
@@ -13279,11 +13602,17 @@ old Agent tool payloads can be read while new payloads stay explicit.
#### AgentSoulKnowledgeConfig
Top-level Agent v2 knowledge config.
Agent v2 models knowledge as explicit sets instead of one flat
``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
list means no knowledge layer should be emitted at runtime, while set-name
uniqueness stays case-insensitive because runtime selection addresses sets
by name.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
| query_config | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | No |
| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No |
| sets | [ [AgentKnowledgeSetConfig](#agentknowledgesetconfig) ] | | No |
#### AgentSoulMemoryConfig
@@ -13756,6 +14085,36 @@ Enum class for api provider schema type.
| use_icon_as_answer_icon | boolean | | No |
| workflow | [WorkflowPartial](#workflowpartial) | | No |
#### AppDetailSiteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| access_token | string | | No |
| app_base_url | string | | No |
| chat_color_theme | string | | No |
| chat_color_theme_inverted | boolean | | No |
| code | string | | No |
| copyright | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| custom_disclaimer | string | | No |
| customize_domain | string | | No |
| customize_token_strategy | string | | No |
| default_language | string | | No |
| description | string | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string<br>[IconType](#icontype) | | No |
| icon_url | string | | Yes |
| input_placeholder | string | | No |
| privacy_policy | string | | No |
| prompt_public | boolean | | No |
| show_workflow_steps | boolean | | No |
| title | string | | No |
| updated_at | integer | | No |
| updated_by | string | | No |
| use_icon_as_answer_icon | boolean | | No |
#### AppDetailWithSite
| Name | Type | Description | Required |
@@ -13781,7 +14140,7 @@ Enum class for api provider schema type.
| model_config | [ModelConfig](#modelconfig) | | No |
| name | string | | Yes |
| permission_keys | [ string ] | | No |
| site | [Site](#site) | | No |
| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No |
| tags | [ [Tag](#tag) ] | | No |
| tracing | [JSONValue](#jsonvalue) | | No |
| updated_at | integer | | No |
@@ -13925,6 +14284,7 @@ AppMCPServer Status Enum
| description | string | | No |
| icon | string | | No |
| icon_background | string | | No |
| input_placeholder | string | | No |
| privacy_policy | string | | No |
| prompt_public | boolean | | Yes |
| show_workflow_steps | boolean | | Yes |
@@ -13952,6 +14312,7 @@ AppMCPServer Status Enum
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| input_placeholder | string | | No |
| privacy_policy | string | | No |
| prompt_public | boolean | | No |
| show_workflow_steps | boolean | | No |
@@ -14185,6 +14546,7 @@ Button styles for user actions.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conversation_id | string | Conversation ID | No |
| draft_type | string, <br>**Available values:** "debug_build", "draft", <br>**Default:** draft | Agent App debug config source. Use debug_build while the Agent is in build mode.<br>*Enum:* `"debug_build"`, `"draft"` | No |
| files | [ object ] | Uploaded files | No |
| inputs | object | | Yes |
| model_config | object | | No |
@@ -19265,6 +19627,7 @@ Simple provider entity response.
| icon_background | string | | No |
| icon_type | string | | No |
| icon_url | string | | Yes |
| input_placeholder | string | | No |
| privacy_policy | string | | No |
| show_workflow_steps | boolean | | Yes |
| title | string | | Yes |
@@ -20352,6 +20715,12 @@ How a workflow node is bound to an Agent.
| ---- | ---- | ----------- | -------- |
| WorkflowAgentBindingType | string | How a workflow node is bound to an Agent. | |
#### WorkflowAgentComposerQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| snapshot_id | string | | No |
#### WorkflowAgentComposerResponse
| Name | Type | Description | Required |
@@ -20360,8 +20729,11 @@ How a workflow node is bound to an Agent.
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | No |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| app_id | string | | No |
| backing_app_id | string | | No |
| binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No |
| chat_endpoint | string | | No |
| effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No |
| hidden_app_backed | boolean | | No |
| impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No |
| node_id | string | | No |
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
+1
View File
@@ -3999,6 +3999,7 @@ Model class for provider with models response.
| icon_background | string | | No |
| icon_type | string | | No |
| icon_url | string | | Yes |
| input_placeholder | string | | No |
| privacy_policy | string | | No |
| show_workflow_steps | boolean | | Yes |
| title | string | | Yes |
+1
View File
@@ -1006,6 +1006,7 @@ Returns Server-Sent Events stream.
| icon_background | string | | No |
| icon_type | string | | No |
| icon_url | string | | No |
| input_placeholder | string | | No |
| privacy_policy | string | | No |
| prompt_public | boolean | | No |
| show_workflow_steps | boolean | | No |
+28 -12
View File
@@ -25,6 +25,7 @@ from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
)
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
MAX_CANDIDATES_PER_LIST = 200
@@ -139,19 +140,34 @@ def soul_candidates(
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
dataset_ids = list_agent_soul_knowledge_dataset_ids(soul)
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
knowledge_datasets: list[dict[str, Any]] = []
for dataset in soul.knowledge.datasets:
if not dataset.id:
continue
row = dataset_rows.get(dataset.id)
knowledge_datasets.append(
knowledge_sets: list[dict[str, Any]] = []
for knowledge_set in soul.knowledge.sets:
missing_dataset_ids: list[str] = []
datasets: list[dict[str, Any]] = []
for dataset in knowledge_set.datasets:
dataset_id = (dataset.id or "").strip()
if not dataset_id:
continue
row = dataset_rows.get(dataset_id)
if row is None:
missing_dataset_ids.append(dataset_id)
datasets.append(
{
"id": dataset_id,
"name": (getattr(row, "name", None) or dataset.name or dataset_id),
"description": getattr(row, "description", None) or dataset.description,
"missing": row is None,
}
)
knowledge_sets.append(
{
"id": dataset.id,
"name": (getattr(row, "name", None) or dataset.name or dataset.id),
"description": getattr(row, "description", None) or dataset.description,
"missing": row is None,
"id": knowledge_set.id,
"name": knowledge_set.name,
"description": knowledge_set.description,
"datasets": datasets,
"missing_dataset_ids": missing_dataset_ids,
}
)
@@ -161,7 +177,7 @@ def soul_candidates(
lists = {
"dify_tools": dify_tools,
"cli_tools": cli_tools,
"knowledge_datasets": knowledge_datasets,
"knowledge_sets": knowledge_sets,
"human_contacts": human_contacts,
}
capped: dict[str, list[dict[str, Any]]] = {}
+550 -115
View File
@@ -11,6 +11,8 @@ from libs.helper import to_timestamp
from models import Account
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@@ -37,6 +39,10 @@ from services.agent.errors import (
AgentVersionNotFoundError,
InvalidComposerConfigError,
)
from services.agent.knowledge_datasets import (
get_tenant_knowledge_dataset_rows,
list_missing_tenant_knowledge_dataset_ids,
)
from services.agent.roster_service import AgentRosterService
from services.app_service import AppService, CreateAppParams
from services.entities.agent_entities import (
@@ -90,26 +96,68 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non
ComposerConfigValidator.validate_draft_save_payload(payload)
def _agent_soul_config_json(agent_soul: AgentSoulConfig | dict[str, Any]) -> dict[str, Any]:
return AgentSoulConfig.model_validate(agent_soul).model_dump(mode="json")
class AgentComposerService:
@classmethod
def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]:
def load_workflow_composer(
cls, *, tenant_id: str, app_id: str, node_id: str, snapshot_id: str | None = None
) -> dict[str, Any]:
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
if not binding:
if snapshot_id:
raise AgentVersionNotFoundError()
return cls._empty_workflow_state(app_id=app_id, workflow_id=workflow.id, node_id=node_id)
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
version = cls._workflow_composer_version(
tenant_id=tenant_id,
binding=binding,
agent=agent,
snapshot_id=snapshot_id,
)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
@classmethod
def _workflow_composer_version(
cls,
*,
tenant_id: str,
binding: WorkflowAgentNodeBinding,
agent: Agent | None,
snapshot_id: str | None,
) -> AgentConfigSnapshot | None:
if snapshot_id:
if agent is None:
raise AgentVersionNotFoundError()
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT:
if agent.scope != AgentScope.ROSTER:
raise AgentVersionNotFoundError()
elif binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
if (
agent.scope != AgentScope.WORKFLOW_ONLY
or agent.app_id != binding.app_id
or agent.workflow_id != binding.workflow_id
or agent.workflow_node_id != binding.node_id
):
raise AgentVersionNotFoundError()
else:
raise AgentVersionNotFoundError()
return cls._require_version(tenant_id=tenant_id, agent_id=agent.id, version_id=snapshot_id)
version_id = (
agent.active_config_snapshot_id
if agent and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else binding.current_snapshot_id
)
version = cls._get_version_if_present(
return cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id if agent else None,
version_id=version_id,
)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
@classmethod
def save_workflow_composer(
@@ -120,6 +168,7 @@ class AgentComposerService:
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
@@ -259,31 +308,37 @@ class AgentComposerService:
@classmethod
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
agent = cls._require_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent)
@classmethod
def load_agent_composer(cls, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent)
@classmethod
def _load_agent_composer_for_agent(cls, *, tenant_id: str, agent: Agent) -> dict[str, Any]:
draft = cls._get_or_create_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
created_by=agent.updated_by or agent.created_by,
)
if not agent:
raise AgentNotFoundError()
version = cls._require_version(
version = cls._get_version_if_present(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
return {
"variant": ComposerVariant.AGENT_APP.value,
"agent": cls._serialize_agent(agent),
"active_config_snapshot": cls._serialize_version(version),
"agent_soul": version.config_snapshot_dict,
"save_options": [
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value,
],
"draft": cls._serialize_draft(draft),
"agent_soul": draft.config_snapshot_dict,
"save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
"app_id": agent.app_id,
"backing_app_id": agent.backing_app_id or agent.app_id,
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
"chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages",
}
@classmethod
@@ -292,22 +347,17 @@ class AgentComposerService:
) -> dict[str, Any]:
if payload.variant != ComposerVariant.AGENT_APP:
raise ValueError("Agent App composer endpoint only accepts agent_app variant")
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION:
raise InvalidComposerConfigError(
"Agent App composer only saves the normal draft. Use the publish endpoint to create a version."
)
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
if not agent:
agent = Agent(
tenant_id=tenant_id,
@@ -317,6 +367,7 @@ class AgentComposerService:
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
app_id=app_id,
backing_app_id=app_id,
status=AgentStatus.ACTIVE,
created_by=account_id,
updated_by=account_id,
@@ -327,35 +378,59 @@ class AgentComposerService:
except IntegrityError as exc:
db.session.rollback()
raise AgentNameConflictError() from exc
return cls._save_agent_composer_for_agent(
tenant_id=tenant_id,
agent=agent,
account_id=account_id,
payload=payload,
)
if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id:
version = cls._create_config_version(
tenant_id=tenant_id,
agent_id=agent.id,
account_id=account_id,
agent_soul=payload.agent_soul,
operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION,
version_note=payload.version_note,
@classmethod
def save_agent_composer(
cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload
) -> dict[str, Any]:
if payload.variant != ComposerVariant.AGENT_APP:
raise ValueError("Agent composer endpoint only accepts agent_app variant")
if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION:
raise InvalidComposerConfigError(
"Agent composer only saves the normal draft. Use the publish endpoint to create a version."
)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
else:
current_snapshot = cls._require_version(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
version = cls._update_current_version(
current_snapshot=current_snapshot,
account_id=account_id,
agent_soul=payload.agent_soul,
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
version_note=payload.version_note,
)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
agent.updated_by = account_id
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
return cls._save_agent_composer_for_agent(
tenant_id=tenant_id,
agent=agent,
account_id=account_id,
payload=payload,
)
@classmethod
def _save_agent_composer_for_agent(
cls, *, tenant_id: str, agent: Agent, account_id: str, payload: ComposerSavePayload
) -> dict[str, Any]:
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
cls._save_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
agent_soul=payload.agent_soul,
account_id_for_audit=account_id,
)
agent.updated_by = account_id
agent.active_config_is_published = cls._agent_soul_matches_active_config(
tenant_id=tenant_id,
agent=agent,
agent_soul=payload.agent_soul,
)
db.session.commit()
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id)
state["validation"] = cls.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
@@ -363,6 +438,214 @@ class AgentComposerService:
)
return state
@classmethod
def _agent_soul_matches_active_config(
cls,
*,
tenant_id: str,
agent: Agent,
agent_soul: AgentSoulConfig,
) -> bool:
if not agent.active_config_snapshot_id:
return False
active_version = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id,
version_id=agent.active_config_snapshot_id,
)
if not active_version:
return False
if agent.source == AgentSource.AGENT_APP and not cls._has_publish_visible_revision(
tenant_id=tenant_id,
agent_id=agent.id,
snapshot_id=agent.active_config_snapshot_id,
):
return False
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict)
@classmethod
def _has_publish_visible_revision(cls, *, tenant_id: str, agent_id: str, snapshot_id: str) -> bool:
revisions = db.session.scalars(
select(AgentConfigRevision.operation).where(
AgentConfigRevision.tenant_id == tenant_id,
AgentConfigRevision.agent_id == agent_id,
AgentConfigRevision.current_snapshot_id == snapshot_id,
)
).all()
return any(
operation
in {
AgentConfigRevisionOperation.PUBLISH_DRAFT,
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
AgentConfigRevisionOperation.RESTORE_VERSION,
}
for operation in revisions
)
@classmethod
def publish_agent_app_draft(
cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
) -> dict[str, Any]:
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP:
raise AgentNotFoundError()
draft = cls._get_or_create_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
created_by=account_id,
)
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
ComposerConfigValidator.validate_publish_payload(
ComposerSavePayload(
variant=ComposerVariant.AGENT_APP,
agent_soul=agent_soul,
save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
version_note=version_note,
)
)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul)
version = cls._create_config_version(
tenant_id=tenant_id,
agent_id=agent.id,
account_id=account_id,
agent_soul=agent_soul,
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
version_note=version_note,
previous_snapshot_id=agent.active_config_snapshot_id,
)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(agent_soul)
agent.active_config_is_published = True
agent.updated_by = account_id
draft.base_snapshot_id = version.id
draft.updated_by = account_id
db.session.commit()
return {
"result": "success",
"active_config_snapshot_id": version.id,
"active_config_snapshot": cls._serialize_version(version),
"draft": cls._serialize_draft(draft),
}
@classmethod
def checkout_agent_app_build_draft(
cls, *, tenant_id: str, agent_id: str, account_id: str, force: bool = False
) -> dict[str, Any]:
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
normal_draft = cls._get_or_create_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
created_by=account_id,
)
build_draft = cls._get_agent_draft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
)
if build_draft is not None and not force:
return cls._serialize_build_draft_state(build_draft)
if build_draft is None:
build_draft = AgentConfigDraft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
draft_owner_key=account_id,
created_by=account_id,
)
db.session.add(build_draft)
build_draft.base_snapshot_id = normal_draft.base_snapshot_id
build_draft.config_snapshot = AgentSoulConfig.model_validate(normal_draft.config_snapshot_dict)
build_draft.updated_by = account_id
db.session.commit()
return cls._serialize_build_draft_state(build_draft)
@classmethod
def load_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
build_draft = cls._get_agent_draft(
tenant_id=tenant_id,
agent_id=agent_id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
)
if build_draft is None:
raise AgentVersionNotFoundError()
return cls._serialize_build_draft_state(build_draft)
@classmethod
def save_agent_app_build_draft(
cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload
) -> dict[str, Any]:
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
ComposerConfigValidator.validate_draft_save_payload(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
build_draft = cls._save_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
agent_soul=payload.agent_soul,
account_id_for_audit=account_id,
)
db.session.commit()
return cls._serialize_build_draft_state(build_draft)
@classmethod
def apply_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
build_draft = cls._get_agent_draft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
)
if build_draft is None:
raise AgentVersionNotFoundError()
applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict)
normal_draft = cls._save_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
agent_soul=applied_agent_soul,
account_id_for_audit=account_id,
base_snapshot_id=build_draft.base_snapshot_id,
)
agent.active_config_is_published = cls._agent_soul_matches_active_config(
tenant_id=tenant_id,
agent=agent,
agent_soul=applied_agent_soul,
)
agent.updated_by = account_id
db.session.delete(build_draft)
db.session.commit()
return {"result": "success", "draft": cls._serialize_draft(normal_draft)}
@classmethod
def discard_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
build_draft = cls._get_agent_draft(
tenant_id=tenant_id,
agent_id=agent_id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
)
if build_draft is not None:
db.session.delete(build_draft)
db.session.commit()
return {"result": "success"}
@classmethod
def collect_validation_findings(
cls,
@@ -372,19 +655,15 @@ class AgentComposerService:
agent_id: str | None = None,
) -> dict[str, Any]:
"""ENG-617 soft findings, with DB-backed dataset and drive mention checks."""
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
mentioned_ids: set[str] = set()
if payload.agent_soul is not None:
mentioned_ids |= {
mention.ref_id
for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt)
if mention.kind == MentionKind.KNOWLEDGE
}
existing_dataset_ids: set[str] | None = None
if mentioned_ids:
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
existing_knowledge_set_ids = (
{knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets}
if payload.agent_soul is not None
else None
)
findings = ComposerConfigValidator.collect_soft_findings(
payload,
existing_knowledge_set_ids=existing_knowledge_set_ids,
)
if agent_id and payload.agent_soul is not None:
findings["warnings"].extend(
cls._drive_mention_findings(
@@ -395,6 +674,24 @@ class AgentComposerService:
)
return findings
@classmethod
def validate_knowledge_datasets(cls, *, tenant_id: str, agent_soul: AgentSoulConfig | None) -> None:
"""Hard-validate tenant-scoped knowledge set datasets before saving.
DTO validators own set shape, duplicate set ids/names, and duplicate
dataset ids within one set. This service-level check owns database
existence and tenant ownership so invalid or cross-tenant datasets fail
before Agent Soul snapshots are persisted.
"""
if agent_soul is None:
return
missing_ids = list_missing_tenant_knowledge_dataset_ids(tenant_id=tenant_id, agent_soul=agent_soul)
if missing_ids:
raise InvalidComposerConfigError(
"knowledge_dataset_not_found: knowledge sets reference missing or out-of-scope datasets: "
+ ", ".join(missing_ids)
)
@classmethod
def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None:
"""The Agent App's bound roster agent id, if any (validate-endpoint context)."""
@@ -509,7 +806,7 @@ class AgentComposerService:
soul_lists, soul_truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
)
truncated = truncated or soul_truncated
@@ -529,14 +826,14 @@ class AgentComposerService:
return response.model_dump(mode="json")
@classmethod
def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]:
def get_agent_app_candidates(cls, *, tenant_id: str, agent_id: str, user_id: str) -> dict[str, Any]:
"""Slash-menu data source for the Agent App (Console) composer (ENG-615)."""
from services.agent.composer_candidates import soul_candidates
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
agent_soul = cls._load_agent_soul(tenant_id=tenant_id, agent_id=agent_id)
soul_lists, truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
)
response = ComposerCandidatesResponse(
@@ -568,24 +865,18 @@ class AgentComposerService:
return cls._parse_soul_snapshot(version)
@classmethod
def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
def _load_agent_soul(cls, *, tenant_id: str, agent_id: str) -> AgentSoulConfig | None:
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=agent_id)
if agent is None:
return None
version = cls._get_version_if_present(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
draft = cls._get_or_create_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
created_by=agent.updated_by or agent.created_by,
)
return cls._parse_soul_snapshot(version)
return AgentSoulConfig.model_validate(draft.config_snapshot_dict)
@staticmethod
def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
@@ -629,30 +920,6 @@ class AgentComposerService:
variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id)
return [(variable.name, variable.value_type.value) for variable in variables.variables]
@staticmethod
def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
"""Tenant-scoped dataset lookup tolerating malformed ids.
Mention ids come from user-editable prompt text; a non-UUID id can never
match a dataset row, so it is simply absent from the result (-> missing/
placeholder semantics) instead of breaking the UUID-typed query.
"""
from uuid import UUID
from services.dataset_service import DatasetService
valid_ids: list[str] = []
for dataset_id in dataset_ids:
try:
UUID(dataset_id)
except (ValueError, TypeError):
continue
valid_ids.append(dataset_id)
if not valid_ids:
return {}
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
return {str(row.id): row for row in rows}
@staticmethod
def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]:
"""Workspace Dify Plugin tools, same source as the tool selector.
@@ -780,6 +1047,7 @@ class AgentComposerService:
raise ValueError("Inline workflow agent binding must point to a workflow-only agent")
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
agent.active_config_is_published = True
agent.updated_by = account_id
binding.current_snapshot_id = version.id
binding.updated_by = account_id
@@ -878,6 +1146,7 @@ class AgentComposerService:
agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
agent.active_config_is_published = True
agent.updated_by = account_id
binding.current_snapshot_id = version.id
if payload.node_job is not None:
@@ -908,6 +1177,7 @@ class AgentComposerService:
agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
agent.active_config_is_published = True
agent.updated_by = account_id
binding.current_snapshot_id = version.id
binding.updated_by = account_id
@@ -1028,6 +1298,15 @@ class AgentComposerService:
icon: str | None = None,
icon_background: str | None = None,
) -> Agent:
backing_app = AgentRosterService(db.session).create_hidden_backing_app_for_workflow_agent(
tenant_id=tenant_id,
account_id=account_id,
name=name or f"Workflow Agent {node_id}",
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
agent = Agent(
tenant_id=tenant_id,
name=name or f"Workflow Agent {node_id}",
@@ -1040,6 +1319,7 @@ class AgentComposerService:
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
app_id=app_id,
backing_app_id=backing_app.id,
workflow_id=workflow_id,
workflow_node_id=node_id,
status=AgentStatus.ACTIVE,
@@ -1058,6 +1338,7 @@ class AgentComposerService:
)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(agent_soul)
agent.active_config_is_published = True
return agent
@classmethod
@@ -1205,6 +1486,7 @@ class AgentComposerService:
)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(agent_soul)
agent.active_config_is_published = True
agent.updated_by = account_id
return agent
@@ -1285,6 +1567,145 @@ class AgentComposerService:
or 0
) + 1
@classmethod
def _get_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent | None:
return db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
@classmethod
def _require_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent:
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
if agent is None:
raise AgentNotFoundError()
return agent
@classmethod
def _get_agent_draft(
cls,
*,
tenant_id: str,
agent_id: str,
draft_type: AgentConfigDraftType,
account_id: str | None,
) -> AgentConfigDraft | None:
stmt = select(AgentConfigDraft).where(
AgentConfigDraft.tenant_id == tenant_id,
AgentConfigDraft.agent_id == agent_id,
AgentConfigDraft.draft_type == draft_type,
)
if draft_type == AgentConfigDraftType.DEBUG_BUILD:
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
else:
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
return db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
@classmethod
def _get_or_create_agent_draft(
cls,
*,
tenant_id: str,
agent: Agent,
draft_type: AgentConfigDraftType,
account_id: str | None,
created_by: str | None,
) -> AgentConfigDraft:
draft = cls._get_agent_draft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=draft_type,
account_id=account_id,
)
if draft is not None:
return draft
base_snapshot = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id,
version_id=agent.active_config_snapshot_id,
)
agent_soul = (
AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict)
if base_snapshot is not None
else AgentSoulConfig()
)
draft = AgentConfigDraft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=draft_type,
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 "",
base_snapshot_id=base_snapshot.id if base_snapshot else None,
config_snapshot=agent_soul,
created_by=created_by,
updated_by=created_by,
)
db.session.add(draft)
db.session.flush()
return draft
@classmethod
def _save_agent_draft(
cls,
*,
tenant_id: str,
agent: Agent,
draft_type: AgentConfigDraftType,
account_id: str | None,
agent_soul: AgentSoulConfig,
account_id_for_audit: str,
base_snapshot_id: str | None = None,
) -> AgentConfigDraft:
draft = cls._get_or_create_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=draft_type,
account_id=account_id,
created_by=account_id_for_audit,
)
draft.config_snapshot = agent_soul
if base_snapshot_id is not None:
draft.base_snapshot_id = base_snapshot_id
elif draft.base_snapshot_id is None:
draft.base_snapshot_id = agent.active_config_snapshot_id
draft.updated_by = account_id_for_audit
if draft_type == AgentConfigDraftType.DRAFT and account_id is None:
agent.active_config_is_published = False
db.session.flush()
return draft
@classmethod
def _serialize_draft(cls, draft: AgentConfigDraft | None) -> dict[str, Any] | None:
if draft is None:
return None
return {
"id": draft.id,
"agent_id": draft.agent_id,
"draft_type": draft.draft_type.value,
"account_id": draft.account_id,
"base_snapshot_id": draft.base_snapshot_id,
"created_by": draft.created_by,
"updated_by": draft.updated_by,
"created_at": to_timestamp(draft.created_at),
"updated_at": to_timestamp(draft.updated_at),
}
@classmethod
def _serialize_build_draft_state(cls, draft: AgentConfigDraft) -> dict[str, Any]:
return {
"variant": ComposerVariant.AGENT_APP.value,
"draft": cls._serialize_draft(draft),
"agent_soul": draft.config_snapshot_dict,
}
@classmethod
def _get_draft_workflow(cls, *, tenant_id: str, app_id: str) -> Workflow:
workflow = db.session.scalar(
@@ -1471,6 +1892,12 @@ class AgentComposerService:
"impact_summary": cls.calculate_impact(tenant_id=binding.tenant_id, current_snapshot_id=version.id)
if version
else None,
"app_id": binding.app_id,
"backing_app_id": agent.backing_app_id if agent else None,
"hidden_app_backed": bool(agent and agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
"chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages" if agent else None,
"workflow_id": binding.workflow_id,
"node_id": binding.node_id,
}
@classmethod
@@ -1479,7 +1906,15 @@ class AgentComposerService:
"id": agent.id,
"name": agent.name,
"description": agent.description,
"role": agent.role,
"icon_type": agent.icon_type,
"icon": agent.icon,
"icon_background": agent.icon_background,
"scope": agent.scope.value,
"source": agent.source.value,
"app_id": agent.app_id,
"backing_app_id": agent.backing_app_id or agent.app_id,
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
"status": agent.status.value,
"active_config_snapshot_id": agent.active_config_snapshot_id,
}
+5 -5
View File
@@ -148,15 +148,15 @@ class ComposerConfigValidator:
cls,
payload: ComposerSavePayload,
*,
existing_dataset_ids: set[str] | None = None,
existing_knowledge_set_ids: set[str] | None = None,
) -> dict[str, Any]:
"""ENG-617 §5.3/§5.4 soft findings — never block save.
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge-set
mentions with a placeholder name (0522 consensus) instead of dropping or
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
deleted datasets surface as placeholders too.
rejecting them. With ``existing_knowledge_set_ids`` provided, mentions
that no longer exist in the current Agent Soul surface as placeholders too.
"""
warnings: list[dict[str, Any]] = []
placeholders: list[dict[str, str]] = []
@@ -188,7 +188,7 @@ class ComposerConfigValidator:
resolved = resolver(mention)
if mention.kind == MentionKind.KNOWLEDGE:
dangling = resolved is None or (
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
existing_knowledge_set_ids is not None and mention.ref_id not in existing_knowledge_set_ids
)
if dangling:
placeholders.append(
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import Any
from uuid import UUID
from models.agent_config_entities import AgentSoulConfig
def list_agent_soul_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
"""Return normalized unique knowledge dataset ids in config order.
Agent v2 knowledge dataset selection is owned by ``knowledge.sets``. This
helper keeps composer, workflow validation, candidates, and runtime
diagnostics aligned on the same normalization rules: strip whitespace, drop
blanks, preserve first-seen order, and deduplicate.
"""
dataset_ids: list[str] = []
seen: set[str] = set()
for knowledge_set in agent_soul.knowledge.sets:
for dataset in knowledge_set.datasets:
dataset_id = (dataset.id or "").strip()
if not dataset_id or dataset_id in seen:
continue
seen.add(dataset_id)
dataset_ids.append(dataset_id)
return dataset_ids
def get_tenant_knowledge_dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
"""Return tenant-scoped dataset rows for normalized knowledge dataset ids.
Knowledge ids come from user-editable config. Malformed ids can never match
a dataset row, so they are treated as missing instead of breaking the
UUID-typed dataset lookup.
"""
from services.dataset_service import DatasetService
valid_ids: list[str] = []
for dataset_id in dataset_ids:
try:
UUID(dataset_id)
except (TypeError, ValueError):
continue
valid_ids.append(dataset_id)
if not valid_ids:
return {}
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
return {str(row.id): row for row in rows}
def list_missing_tenant_knowledge_dataset_ids(*, tenant_id: str, agent_soul: AgentSoulConfig | None) -> list[str]:
"""Return normalized knowledge dataset ids missing from the tenant scope."""
if agent_soul is None:
return []
dataset_ids = list_agent_soul_knowledge_dataset_ids(agent_soul)
if not dataset_ids:
return []
rows = get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=dataset_ids)
return [dataset_id for dataset_id in dataset_ids if dataset_id not in rows]
+114 -35
View File
@@ -1,23 +1,26 @@
"""Prompt mention (slash-reference) serialization contract — ENG-616.
"""Prompt mention and workflow-marker parsing helpers for Agent surfaces.
Slash-menu insertions are stored inline in the plain-string prompt as tokens:
Slash-menu insertions are stored inline as mention tokens:
[§<kind>:<id>[:<label>]§]
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent
runtime context. For prompt-owned entities that means Agent Soul lists such as
``tools`` / ``knowledge.datasets`` / ``human.contacts`` and workflow job lists
such as ``previous_node_output_refs`` / ``declared_outputs``. For drive-backed
``skill`` / ``file`` mentions the field stores a URL-encoded drive key and is
resolved against ``agent_drive_files`` at runtime. ``label`` is an optional
plain-text fallback only. A single ``:`` separates all three fields; ``label``
is the trailing remainder and may itself contain ``:``.
Those tokens point at Agent-owned config such as Soul tools/knowledge/humans or
workflow task config such as ``previous_node_output_refs`` / ``declared_outputs``.
Runtime mention expansion is owned by the run-request builders.
The ``[§§]`` wrapper uses the section sign ``§`` (U+00A7), which never appears
in Dify template syntax (``{{var}}`` / ``{{#a.b#}}``) nor in normal prompt text,
so these tokens can never collide with the existing template parsers. Runtime
expansion (and the final scrub that guarantees no internal marker ever reaches
the model) is owned by the run-request builders.
Workflow Agent tasks also carry frontend workflow variable markers:
{{#<node-id>.<output>[.<child>...]#}}
Those frontend markers are a separate path from slash-reference expansion. They
are parsed here only to derive ``previous_node_output_refs`` from the current
task text. The markers remain literal in the workflow task prompt, while their
resolved values appear under the workflow context prompt's ``Previous node
outputs:`` section. Legacy ``[§node_output:...§]`` mention syntax is not part
of that derivation path.
Frontend output blocks still accept a legacy bare ``§output:...§`` form during
migrations, so the mention parser keeps that alias for output mentions only.
"""
from __future__ import annotations
@@ -47,13 +50,16 @@ class MentionKind(StrEnum):
MENTION_PATTERN = re.compile(
r"\[§(skill|file|tool|cli_tool|knowledge|human|node_output|output):([^:§]+?)(?::([^§]*?))?§\]"
r"(?:\[§(?P<bracket_kind>skill|file|tool|cli_tool|knowledge|human|node_output|output):"
r"(?P<bracket_id>[^:§]+?)(?::(?P<bracket_label>[^§]*?))?§\])"
r"|(?:§(?P<legacy_kind>output):(?P<legacy_id>[^:§]+?)(?::(?P<legacy_label>[^§]*?))?§)"
)
# Anything mention-shaped (``[§word:…§]``) that the strict pattern did not consume
# — unknown kinds, malformed bodies. The ``§`` wrapper + a kind-word + ``:``
# requirement keeps legacy ``{{#histories#}}`` / ``{{var}}`` template forms and
# ordinary bracketed text out of scope.
_RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\]")
WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}")
MAX_MENTIONS_PER_PROMPT = 200
# Drive keys are validated up to 512 Unicode code points before URL encoding.
@@ -71,7 +77,8 @@ MAX_MENTION_LABEL_LENGTH = 255
ALL_PROVIDER_TOOLS_SUFFIX = "*"
# Per-surface allowlists (design §2.4): the soul prompt may only reference
# soul-owned entities; the workflow job prompt may only reference run-scoped ones.
# soul-owned entities; the persisted workflow task prompt may only reference
# run-scoped ones.
SOUL_PROMPT_ALLOWED_KINDS = frozenset(
{
MentionKind.SKILL,
@@ -83,6 +90,9 @@ SOUL_PROMPT_ALLOWED_KINDS = frozenset(
}
)
NODE_JOB_PROMPT_ALLOWED_KINDS = frozenset({MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN})
WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES = frozenset(
{"sys", "env", "conversation", "rag", "current", "last_run", "error_message", "$output"}
)
@dataclass(frozen=True, slots=True)
@@ -100,18 +110,24 @@ class PromptMention:
MentionResolver = Callable[[PromptMention], str | None]
def _mention_groups(match: re.Match[str]) -> tuple[str, str, str | None]:
kind = match.group("bracket_kind") or match.group("legacy_kind")
ref_id = match.group("bracket_id") or match.group("legacy_id")
label = match.group("bracket_label") or match.group("legacy_label")
return kind, ref_id, label
def parse_prompt_mentions(prompt: str) -> list[PromptMention]:
"""Extract well-formed mentions. Oversized id/label tokens are skipped here
(treated as malformed) the runtime scrub still degrades them safely."""
mentions: list[PromptMention] = []
for match in MENTION_PATTERN.finditer(prompt or ""):
ref_id = match.group(2)
label = match.group(3)
kind, ref_id, label = _mention_groups(match)
if len(ref_id) > MAX_MENTION_REF_ID_LENGTH or (label is not None and len(label) > MAX_MENTION_LABEL_LENGTH):
continue
mentions.append(
PromptMention(
kind=MentionKind(match.group(1)),
kind=MentionKind(kind),
ref_id=ref_id,
label=label or None,
start=match.start(),
@@ -130,13 +146,13 @@ def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
return prompt
def _replace(match: re.Match[str]) -> str:
ref_id = match.group(2)
label = match.group(3) or None
kind, ref_id, label = _mention_groups(match)
label = label or None
fallback = (label or ref_id)[:MAX_MENTION_LABEL_LENGTH]
if len(ref_id) > MAX_MENTION_REF_ID_LENGTH or (label is not None and len(label) > MAX_MENTION_LABEL_LENGTH):
return fallback
mention = PromptMention(
kind=MentionKind(match.group(1)),
kind=MentionKind(kind),
ref_id=ref_id,
label=label,
start=match.start(),
@@ -161,6 +177,48 @@ def find_malformed_mention_markers(prompt: str) -> list[str]:
return [match.group(0) for match in _RESIDUAL_MENTION_PATTERN.finditer(prompt) if match.span() not in parsed_spans]
def extract_workflow_variable_selectors(prompt: str) -> list[tuple[str, ...]]:
"""Extract ``{{#node.output#}}``-style selectors from workflow prompts."""
selectors: list[tuple[str, ...]] = []
for match in WORKFLOW_VARIABLE_PATTERN.finditer(prompt or ""):
parts = tuple(part.strip() for part in match.group(1).split(".") if part.strip())
if len(parts) >= 2:
selectors.append(parts)
return selectors
def extract_workflow_node_output_selectors(prompt: str) -> list[tuple[str, ...]]:
"""Extract previous-node selectors from frontend workflow variable markers.
Reserved Dify namespaces such as ``sys`` are excluded because they are not
previous nodes.
"""
selectors: list[tuple[str, ...]] = []
seen: set[tuple[str, ...]] = set()
for selector in extract_workflow_variable_selectors(prompt):
if selector[0] in WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES:
continue
if selector in seen:
continue
selectors.append(selector)
seen.add(selector)
return selectors
def workflow_previous_node_output_refs_from_selectors(
selectors: list[tuple[str, ...]],
) -> list[WorkflowPreviousNodeOutputRef]:
"""Materialize persisted previous-node refs from parsed frontend selectors."""
return [
WorkflowPreviousNodeOutputRef(
selector=list(selector),
node_id=selector[0],
output=selector[1],
)
for selector in selectors
]
def scrub_mention_markers(text: str) -> str:
"""Degrade any residual mention-shaped ``[§kind:…§]`` marker to readable text."""
@@ -211,9 +269,9 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
if mention.ref_id in (cli_tool.id, cli_tool.name):
return cli_tool.name or cli_tool.id
case MentionKind.KNOWLEDGE:
for dataset in agent_soul.knowledge.datasets:
if mention.ref_id == dataset.id:
return dataset.name or dataset.id
for knowledge_set in agent_soul.knowledge.sets:
if mention.ref_id == knowledge_set.id:
return knowledge_set.name or knowledge_set.id
case MentionKind.HUMAN:
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
case _:
@@ -224,14 +282,17 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionResolver:
"""Resolve job-surface mentions. ``node_output`` expands to the stored
reference name only values stay in the Workflow context block (design §4.2)."""
"""Resolve persisted workflow task prompt mentions.
``node_output`` expands to the stored reference name only; values stay in
the workflow context block for the run-scoped ``user_prompt``.
"""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
case MentionKind.NODE_OUTPUT:
for ref in node_job.previous_node_output_refs:
selector = _selector_from_ref(ref)
selector = normalize_previous_node_output_selector(ref)
if selector and f"{selector[0]}.{selector[1]}" == mention.ref_id:
return ref.name or mention.label or mention.ref_id
case MentionKind.OUTPUT:
@@ -256,14 +317,27 @@ def _resolve_human_contact(contacts: list[AgentHumanContactConfig], ref_id: str)
return None
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] | None:
def normalize_previous_node_output_selector(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, ...] | None:
"""Return the canonical previous-node selector for a persisted ref.
Explicit selector arrays win and must contain at least two string parts. The
legacy field form falls back to ``node_id`` plus the first available output
field. Callers that only need node/output identity should compare the first
two returned items.
"""
for candidate in (ref.selector, ref.variable_selector, ref.value_selector):
if isinstance(candidate, list) and len(candidate) >= 2:
return str(candidate[0]), str(candidate[1])
if ref.node_id:
output = ref.output or ref.variable or ref.key
if output:
return ref.node_id, output
selector_parts: list[str] = []
for item in candidate:
if not isinstance(item, str):
break
selector_parts.append(item)
if len(selector_parts) == len(candidate):
return tuple(selector_parts)
node_id = ref.get("node_id")
output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key")
if isinstance(node_id, str) and isinstance(output_name, str):
return node_id, output_name
return None
@@ -275,13 +349,18 @@ __all__ = [
"MENTION_PATTERN",
"NODE_JOB_PROMPT_ALLOWED_KINDS",
"SOUL_PROMPT_ALLOWED_KINDS",
"WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES",
"MentionKind",
"MentionResolver",
"PromptMention",
"build_node_job_mention_resolver",
"build_soul_mention_resolver",
"expand_prompt_mentions",
"extract_workflow_node_output_selectors",
"extract_workflow_variable_selectors",
"find_malformed_mention_markers",
"normalize_previous_node_output_selector",
"parse_prompt_mentions",
"scrub_mention_markers",
"workflow_previous_node_output_refs_from_selectors",
]
+200 -43
View File
@@ -3,11 +3,14 @@ from typing import Any, TypedDict
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from constants.model_template import default_app_templates
from core.app.entities.app_invoke_entities import InvokeFrom
from libs.datetime_utils import naive_utc_now
from libs.helper import to_timestamp
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@@ -21,7 +24,7 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import AppStatus, ConversationFromSource, ConversationStatus
from models.model import App, AppMode, Conversation, IconType
from models.model import App, AppMode, AppModelConfig, Conversation, IconType
from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
@@ -83,7 +86,7 @@ class AgentRosterService:
agent: Agent,
active_version: AgentConfigSnapshot | None = None,
published_references: list[AgentReferencingWorkflow] | None = None,
active_config_is_published: bool = False,
active_config_is_published: bool | None = None,
) -> dict[str, Any]:
published_references = published_references or []
return {
@@ -98,12 +101,16 @@ class AgentRosterService:
"scope": agent.scope.value,
"source": agent.source.value,
"app_id": agent.app_id,
"backing_app_id": agent.backing_app_id,
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
"debug_conversation_id": None,
"workflow_id": agent.workflow_id,
"workflow_node_id": agent.workflow_node_id,
"active_config_snapshot_id": agent.active_config_snapshot_id,
"active_config_snapshot": AgentRosterService.serialize_version(active_version) if active_version else None,
"active_config_is_published": active_config_is_published,
"active_config_is_published": agent.active_config_is_published
if active_config_is_published is None
else active_config_is_published,
"status": agent.status.value,
"created_by": agent.created_by,
"updated_by": agent.updated_by,
@@ -321,6 +328,7 @@ class AgentRosterService:
self._session.add(revision)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
agent.active_config_is_published = True
try:
self._session.commit()
@@ -363,6 +371,7 @@ class AgentRosterService:
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
app_id=app_id,
backing_app_id=app_id,
created_by=account_id,
updated_by=account_id,
)
@@ -394,10 +403,58 @@ class AgentRosterService:
self._session.add(revision)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(AgentSoulConfig())
agent.active_config_is_published = False
self._session.flush()
self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id)
return agent
def create_hidden_backing_app_for_workflow_agent(
self,
*,
tenant_id: str,
account_id: str | None,
name: str,
description: str = "",
icon_type: Any = None,
icon: str | None = None,
icon_background: str | None = None,
) -> App:
"""Create an internal Agent App used only to back a workflow-only Agent.
This deliberately bypasses AppService.create_app because that public
creation path also creates a roster Agent. Inline Agents need App runtime
infrastructure for chat/logs/monitoring, but must stay hidden from the
workspace Agent Roster until explicitly saved to roster.
"""
app_template = dict(default_app_templates[AppMode.AGENT]["app"])
app = App(**app_template)
app.name = name
app.description = description or ""
app.mode = AppMode.AGENT
normalized_icon_type = self._normalize_app_icon_type(icon_type)
app.icon_type = IconType(normalized_icon_type) if normalized_icon_type else IconType.EMOJI
app.icon = icon
app.icon_background = icon_background
app.tenant_id = tenant_id
app.enable_site = False
app.enable_api = False
app.api_rph = 0
app.api_rpm = 0
app.max_active_requests = None
app.created_by = account_id
app.maintainer = account_id
app.updated_by = account_id
self._session.add(app)
self._session.flush()
app_model_config = AppModelConfig(app_id=app.id, created_by=account_id, updated_by=account_id)
self._session.add(app_model_config)
self._session.flush()
app.app_model_config_id = app_model_config.id
self._session.flush()
return app
def _create_agent_app_debug_conversation(self, *, app_id: str, account_id: str) -> str:
"""Create one console debug conversation for an Agent App editor."""
@@ -423,8 +480,31 @@ class AgentRosterService:
self._session.flush()
return conversation.id
@staticmethod
def runtime_backing_app_id(agent: Agent) -> str | None:
"""Return the App id that backs Agent runtime chat/log/monitoring."""
return agent.backing_app_id or agent.app_id
def _ensure_workflow_agent_backing_app(self, *, agent: Agent, account_id: str | None) -> str | None:
if agent.scope != AgentScope.WORKFLOW_ONLY or agent.backing_app_id:
return self.runtime_backing_app_id(agent)
backing_app = self.create_hidden_backing_app_for_workflow_agent(
tenant_id=agent.tenant_id,
account_id=account_id or agent.updated_by or agent.created_by,
name=agent.name,
description=agent.description,
icon_type=agent.icon_type,
icon=agent.icon,
icon_background=agent.icon_background,
)
agent.backing_app_id = backing_app.id
self._session.flush()
return backing_app.id
def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str:
if not agent.app_id:
backing_app_id = self._ensure_workflow_agent_backing_app(agent=agent, account_id=account_id)
if not backing_app_id:
raise AgentNotFoundError()
mapping = self._session.scalar(
@@ -438,7 +518,7 @@ class AgentRosterService:
conversation_id = self._session.scalar(
select(Conversation.id).where(
Conversation.id == mapping.conversation_id,
Conversation.app_id == agent.app_id,
Conversation.app_id == backing_app_id,
Conversation.from_source == ConversationFromSource.CONSOLE,
Conversation.from_account_id == account_id,
Conversation.is_deleted.is_(False),
@@ -448,21 +528,22 @@ class AgentRosterService:
return conversation_id
mapping.conversation_id = self._create_agent_app_debug_conversation(
app_id=agent.app_id,
app_id=backing_app_id,
account_id=account_id,
)
mapping.app_id = backing_app_id
self._session.flush()
return mapping.conversation_id
conversation_id = self._create_agent_app_debug_conversation(
app_id=agent.app_id,
app_id=backing_app_id,
account_id=account_id,
)
self._session.add(
AgentDebugConversation(
tenant_id=agent.tenant_id,
agent_id=agent.id,
app_id=agent.app_id,
app_id=backing_app_id,
account_id=account_id,
conversation_id=conversation_id,
)
@@ -479,8 +560,6 @@ class AgentRosterService:
select(Agent).where(
Agent.tenant_id == tenant_id,
Agent.id == agent_id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
)
@@ -501,16 +580,20 @@ class AgentRosterService:
select(Agent).where(
Agent.tenant_id == tenant_id,
Agent.id == agent_id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
)
if agent is None or not agent.app_id:
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()
conversation_id = self._create_agent_app_debug_conversation(
app_id=agent.app_id,
app_id=backing_app_id,
account_id=account_id,
)
mapping = self._session.scalar(
@@ -525,13 +608,13 @@ class AgentRosterService:
AgentDebugConversation(
tenant_id=tenant_id,
agent_id=agent_id,
app_id=agent.app_id,
app_id=backing_app_id,
account_id=account_id,
conversation_id=conversation_id,
)
)
else:
mapping.app_id = agent.app_id
mapping.app_id = backing_app_id
mapping.conversation_id = conversation_id
self._session.flush()
if commit:
@@ -546,11 +629,7 @@ class AgentRosterService:
conversation_ids_by_agent_id: dict[str, str] = {}
changed = False
for agent in agents:
if (
agent.tenant_id != tenant_id
or agent.scope != AgentScope.ROSTER
or agent.source != AgentSource.AGENT_APP
):
if agent.tenant_id != tenant_id or agent.status != AgentStatus.ACTIVE:
continue
conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation(
agent=agent,
@@ -574,7 +653,7 @@ class AgentRosterService:
Agent.status == AgentStatus.ACTIVE,
)
).all()
return {agent.app_id: agent for agent in agents if agent.app_id}
return {agent.app_id: agent for agent in agents if agent.app_id and agent.id}
def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None:
"""Return the roster Agent that backs the given Agent App, if any."""
@@ -625,6 +704,59 @@ class AgentRosterService:
raise AgentNotFoundError()
return app
def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App:
"""Resolve the App that backs an Agent runtime surface.
Roster Agents use their public Agent App. Workflow-only Agents use a
hidden Agent App stored in ``backing_app_id`` so console chat/logs can
reuse the app runtime without exposing the resource in workspace app
lists.
"""
agent = self._session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.id == agent_id,
Agent.status == AgentStatus.ACTIVE,
or_(
and_(Agent.scope == AgentScope.ROSTER, Agent.source == AgentSource.AGENT_APP),
and_(
Agent.scope == AgentScope.WORKFLOW_ONLY,
Agent.source == AgentSource.WORKFLOW,
Agent.workflow_id.is_not(None),
Agent.workflow_node_id.is_not(None),
),
),
)
.limit(1)
)
if agent is None:
raise AgentNotFoundError()
should_commit_backing_app = agent.scope == AgentScope.WORKFLOW_ONLY and not agent.backing_app_id
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()
if should_commit_backing_app:
self._session.commit()
app = self._session.scalar(
select(App)
.where(
App.tenant_id == tenant_id,
App.id == backing_app_id,
App.mode == AppMode.AGENT,
App.status == AppStatus.NORMAL,
)
.limit(1)
)
if app is None:
raise AgentNotFoundError()
return app
def duplicate_agent_app(
self,
*,
@@ -692,10 +824,10 @@ class AgentRosterService:
return target_app
@staticmethod
def _normalize_app_icon_type(icon_type: IconType | str | None) -> str | None:
def _normalize_app_icon_type(icon_type: Any | None) -> str | None:
if icon_type is None:
return None
if isinstance(icon_type, IconType):
if isinstance(icon_type, IconType) or hasattr(icon_type, "value"):
return icon_type.value
return icon_type
@@ -737,6 +869,7 @@ class AgentRosterService:
target_version.version_note = source_version.version_note
target_version.created_by = account_id
target_agent.active_config_has_model = agent_soul_has_model(target_version.config_snapshot)
target_agent.active_config_is_published = source_agent.active_config_is_published
target_agent.updated_by = account_id
def _next_duplicate_agent_name(self, *, tenant_id: str, base_name: str) -> str:
@@ -836,6 +969,7 @@ class AgentRosterService:
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
if agent.source == AgentSource.AGENT_APP:
return {
AgentConfigRevisionOperation.PUBLISH_DRAFT,
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
AgentConfigRevisionOperation.RESTORE_VERSION,
@@ -849,16 +983,27 @@ class AgentRosterService:
}
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
"""Return whether the Agent's current active snapshot is a visible published version."""
"""Return whether the normal shared draft has been published into the active snapshot."""
return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get(
agent.id,
False,
)
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
"""Return publish-state flags for the active config snapshots of the given Agents."""
published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents)
return {agent.id: agent.id in published_agent_ids for agent in agents}
"""Return each Agent's stored normal-draft publish state.
The flag is maintained by write paths against the normal shared draft:
saves compare the draft content with the active snapshot, while publish
and version creation paths mark the new active snapshot clean.
User-scoped debug drafts intentionally do not affect this state.
"""
agents = [agent for agent in agents if agent.id]
if not agents:
return {}
return {
agent.id: bool(agent.active_config_snapshot_id and agent.active_config_is_published) for agent in agents
}
def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
@@ -957,26 +1102,38 @@ class AgentRosterService:
raise AgentVersionNotFoundError()
version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
if agent.active_config_snapshot_id == version.id:
return {"result": "success", "active_config_snapshot_id": version.id}
previous_snapshot_id = agent.active_config_snapshot_id
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(version.config_snapshot)
agent.updated_by = account_id
self._session.add(
AgentConfigRevision(
draft = self._session.scalar(
select(AgentConfigDraft)
.where(
AgentConfigDraft.tenant_id == tenant_id,
AgentConfigDraft.agent_id == agent_id,
AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT,
AgentConfigDraft.account_id.is_(None),
)
.limit(1)
)
if draft is None:
draft = AgentConfigDraft(
tenant_id=tenant_id,
agent_id=agent_id,
previous_snapshot_id=previous_snapshot_id,
current_snapshot_id=version.id,
revision=self._next_revision(tenant_id=tenant_id, agent_id=agent_id),
operation=AgentConfigRevisionOperation.RESTORE_VERSION,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft_owner_key="",
created_by=account_id,
)
)
self._session.add(draft)
draft.base_snapshot_id = version.id
draft.config_snapshot = AgentSoulConfig.model_validate(version.config_snapshot_dict)
draft.updated_by = account_id
agent.active_config_is_published = version.id == agent.active_config_snapshot_id
agent.updated_by = account_id
self._session.commit()
return {"result": "success", "active_config_snapshot_id": version.id}
return {
"result": "success",
"active_config_snapshot_id": agent.active_config_snapshot_id or version.id,
"draft_config_id": draft.id,
"restored_version_id": version.id,
}
def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent:
stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id)
+169 -60
View File
@@ -1,15 +1,17 @@
"""Validate + extract metadata from an uploaded Skill package (ENG-370).
"""Validate and normalize uploaded Skill packages for drive standardization.
A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entry
file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``,
followed by markdown instructions). This service validates the archive (extension,
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields) and
extracts a manifest consumed by drive standardization.
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields),
normalizes retained member paths relative to the selected skill root, rebuilds
canonical archive bytes, and returns normalized metadata together with the
archive-root ``SKILL.md`` bytes.
It does NOT execute or load the skill the agent backend owns execution. It also
does not persist anything into Agent Soul or bind anything to config versions;
``SkillStandardizeService`` consumes the manifest and commits the canonical drive
rows instead.
``SkillStandardizeService`` consumes the normalized package and commits the
canonical drive rows instead.
"""
from __future__ import annotations
@@ -19,6 +21,7 @@ import io
import posixpath
import re
import zipfile
import zlib
import yaml
from pydantic import BaseModel
@@ -58,10 +61,69 @@ class SkillManifest(BaseModel):
hash: str # sha256 of the archive bytes
class SkillPackageService:
"""Validate Skill archives and extract their manifest."""
class NormalizedSkillPackage(BaseModel):
"""Canonical skill package bytes and metadata ready to store in agent drive."""
def validate_and_extract(self, *, content: bytes, filename: str) -> SkillManifest:
manifest: SkillManifest
archive_bytes: bytes
skill_md_bytes: bytes
strip_prefix: str | None
class SkillPackageService:
"""Validate Skill archives and produce the normalized package stored in drive."""
def validate_and_normalize(self, *, content: bytes, filename: str) -> NormalizedSkillPackage:
"""Return the canonical drive package for an uploaded skill archive.
The shallowest ``SKILL.md`` defines the skill root. When exactly one
depth-2 ``<folder>/SKILL.md`` exists, normalization strips that top-level
folder and silently discards all members outside it, including nested
foreign paths. When that unique depth-2 condition does not apply, files
outside the selected skill root still raise ``files_outside_skill_root``.
The returned manifest is normalized to archive-root ``SKILL.md`` and its
hash describes the rebuilt archive bytes. Member read/decompression
failures while consuming the archive are mapped to ``invalid_archive``.
"""
archive = self._open_archive(content=content, filename=filename)
with archive:
members = self._collect_file_members(archive)
member_paths = [safe_path for _, safe_path in members]
entry_path = self._find_skill_md(member_paths)
strip_prefix = self._skill_root_prefix(entry_path)
normalized_members = self._normalize_members(
members=members,
skill_root_prefix=strip_prefix,
ignore_outside_selected_root=self._can_strip_single_top_level_folder(
paths=member_paths, entry_path=entry_path
),
)
skill_md_member = normalized_members[_SKILL_MD_NAME]
self._validate_skill_md_size(skill_md_member)
skill_md_bytes = self._read_member_bytes_from_archive(archive, member_info=skill_md_member)
skill_md = self._decode_skill_md(skill_md_bytes)
normalized_archive_bytes = self._build_normalized_archive(
archive=archive, normalized_members=normalized_members
)
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
name, description = self._parse_skill_md(skill_md)
manifest = SkillManifest(
name=name,
description=description,
entry_path=_SKILL_MD_NAME,
files=sorted(normalized_members),
size=normalized_size,
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
)
return NormalizedSkillPackage(
manifest=manifest,
archive_bytes=normalized_archive_bytes,
skill_md_bytes=skill_md_bytes,
strip_prefix=strip_prefix,
)
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
self._check_extension(filename)
if not content:
raise SkillPackageError("empty_archive", "skill archive is empty", status_code=400)
@@ -69,52 +131,90 @@ class SkillPackageService:
raise SkillPackageError("archive_too_large", "skill archive exceeds size limit", status_code=400)
try:
archive = zipfile.ZipFile(io.BytesIO(content))
return zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile as exc:
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
with archive:
infos = [info for info in archive.infolist() if not info.is_dir()]
if len(infos) > _MAX_ENTRIES:
raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400)
def _collect_file_members(self, archive: zipfile.ZipFile) -> list[tuple[zipfile.ZipInfo, str]]:
infos = [info for info in archive.infolist() if not info.is_dir()]
if len(infos) > _MAX_ENTRIES:
raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400)
safe_paths: list[str] = []
total_uncompressed = 0
for info in infos:
safe_paths.append(self._safe_member_path(info.filename))
total_uncompressed += max(info.file_size, 0)
if total_uncompressed > _MAX_UNCOMPRESSED_BYTES:
raise SkillPackageError(
"archive_too_large", "skill archive uncompressed size exceeds limit", status_code=400
)
entry_path = self._find_skill_md(safe_paths)
skill_md = self._read_skill_md(archive, entry_path)
name, description = self._parse_skill_md(skill_md)
return SkillManifest(
name=name,
description=description,
entry_path=entry_path,
files=sorted(safe_paths),
size=total_uncompressed,
hash=hashlib.sha256(content).hexdigest(),
)
def read_member_bytes(self, *, content: bytes, member_path: str) -> bytes:
"""Read a single archive member's bytes (used by standardization, ENG-594)."""
try:
archive = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile as exc:
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
with archive:
member = next(
(info for info in archive.infolist() if posixpath.normpath(info.filename) == member_path),
None,
members: list[tuple[zipfile.ZipInfo, str]] = []
total_uncompressed = 0
for info in infos:
members.append((info, self._safe_member_path(info.filename)))
total_uncompressed += max(info.file_size, 0)
if total_uncompressed > _MAX_UNCOMPRESSED_BYTES:
raise SkillPackageError(
"archive_too_large",
"skill archive uncompressed size exceeds limit",
status_code=400,
)
if member is None:
raise SkillPackageError("member_not_found", f"{member_path} not found in archive", status_code=400)
return archive.read(member)
return members
@staticmethod
def _skill_root_prefix(entry_path: str) -> str | None:
skill_root = posixpath.dirname(entry_path)
if not skill_root:
return None
return f"{skill_root}/"
def _normalize_members(
self,
*,
members: list[tuple[zipfile.ZipInfo, str]],
skill_root_prefix: str | None,
ignore_outside_selected_root: bool = False,
) -> dict[str, zipfile.ZipInfo]:
normalized_members: dict[str, zipfile.ZipInfo] = {}
for info, safe_path in members:
if skill_root_prefix is not None:
if not safe_path.startswith(skill_root_prefix):
if ignore_outside_selected_root:
continue
raise SkillPackageError(
"files_outside_skill_root",
"skill archive contains files outside the selected skill root",
status_code=400,
)
normalized_path = safe_path.removeprefix(skill_root_prefix)
else:
normalized_path = safe_path
if (
not normalized_path
or normalized_path in {".", ".."}
or normalized_path.startswith("/")
or "\\" in normalized_path
):
raise SkillPackageError("unsafe_path", "skill archive contains an unsafe path", status_code=400)
if normalized_path in normalized_members:
raise SkillPackageError(
"duplicate_member_path",
"skill archive contains duplicate normalized paths",
status_code=400,
)
normalized_members[normalized_path] = info
if _SKILL_MD_NAME not in normalized_members:
raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400)
return normalized_members
def _build_normalized_archive(
self,
*,
archive: zipfile.ZipFile,
normalized_members: dict[str, zipfile.ZipInfo],
) -> bytes:
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as normalized_archive:
for normalized_path in sorted(normalized_members):
normalized_archive.writestr(
normalized_path,
self._read_member_bytes_from_archive(archive, member_info=normalized_members[normalized_path]),
)
return output.getvalue()
@staticmethod
def _check_extension(filename: str) -> None:
@@ -145,17 +245,26 @@ class SkillPackageService:
return min(candidates, key=lambda p: (p.count("/"), len(p)))
@staticmethod
def _read_skill_md(archive: zipfile.ZipFile, entry_path: str) -> str:
# Look the member up by its original name (normpath may differ from the stored name).
member = next(
(info for info in archive.infolist() if posixpath.normpath(info.filename) == entry_path),
None,
)
if member is None:
raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400)
if member.file_size > _MAX_SKILL_MD_BYTES:
def _can_strip_single_top_level_folder(*, paths: list[str], entry_path: str) -> bool:
if entry_path.count("/") != 1:
return False
candidates = [path for path in paths if path.count("/") == 1 and posixpath.basename(path) == _SKILL_MD_NAME]
return len(candidates) == 1 and candidates[0] == entry_path
@staticmethod
def _read_member_bytes_from_archive(archive: zipfile.ZipFile, *, member_info: zipfile.ZipInfo) -> bytes:
try:
return archive.read(member_info)
except (zipfile.BadZipFile, EOFError, OSError, RuntimeError, ValueError, zlib.error) as exc:
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
@staticmethod
def _validate_skill_md_size(member_info: zipfile.ZipInfo) -> None:
if member_info.file_size > _MAX_SKILL_MD_BYTES:
raise SkillPackageError("skill_md_too_large", "SKILL.md exceeds size limit", status_code=400)
raw = archive.read(member)
@staticmethod
def _decode_skill_md(raw: bytes) -> str:
try:
return raw.decode("utf-8")
except UnicodeDecodeError as exc:
@@ -193,4 +302,4 @@ class SkillPackageService:
return loaded if isinstance(loaded, dict) else {}
__all__ = ["SkillManifest", "SkillPackageError", "SkillPackageService"]
__all__ = ["NormalizedSkillPackage", "SkillManifest", "SkillPackageError", "SkillPackageService"]
+29 -53
View File
@@ -7,19 +7,14 @@ to the agent drive (Agent Files §5.4 / §4):
* ``<slug>/.DIFY-SKILL-FULL.zip`` the full archive, kept only to restore the
complete skill contents.
Both are stored as ``ToolFile`` records and bound via ``AgentDriveService.commit``
with ``value_owned_by_drive=True`` (the drive owns their lifecycle). The returned
payload is the slim drive-derived skill DTO the UI needs to work with the drive
catalog ``name``, ``description``, ``path``, ``skill_md_key``, and
``archive_key`` plus the extracted manifest for upload feedback. The console
``/skills/upload`` endpoints delegate to this service so "upload" now always means
drive-backed skill normalization rather than Agent Soul binding.
The archive's member list is stored in skill metadata and resolved lazily for
inspect/preview/runtime. Upload must not eagerly materialize every archive member
as a separate ToolFile; small archives with many files would otherwise perform
hundreds of storage writes and DB commits inside the request.
"""
from __future__ import annotations
import mimetypes
import posixpath
import re
from typing import Any
@@ -38,7 +33,11 @@ def slugify_skill_name(name: str) -> str:
class SkillStandardizeService:
"""Validate + standardize a Skill package into a per-agent drive upload result."""
"""Persist a normalized skill package into drive-owned files for one agent.
Instances are intentionally stateful: ``standardize()`` updates
``last_committed_items`` with the drive commit result for the most recent call.
"""
def __init__(
self,
@@ -50,6 +49,7 @@ class SkillStandardizeService:
self._package = package_service or SkillPackageService()
self._drive = drive_service or AgentDriveService()
self._tool_files = tool_file_manager or ToolFileManager()
self.last_committed_items: list[dict[str, Any]] = []
def standardize(
self,
@@ -60,17 +60,23 @@ class SkillStandardizeService:
user_id: str,
agent_id: str,
) -> dict[str, Any]:
manifest = self._package.validate_and_extract(content=content, filename=filename)
skill_md_bytes = self._package.read_member_bytes(content=content, member_path=manifest.entry_path)
"""Create two ToolFiles, commit two drive-owned keys, and return skill metadata.
This writes ``<slug>/SKILL.md`` and ``<slug>/.DIFY-SKILL-FULL.zip``,
stores the drive commit rows in ``last_committed_items``, and returns the
console response shape ``{"skill": ..., "manifest": ...}``.
"""
package = self._package.validate_and_normalize(content=content, filename=filename)
manifest = package.manifest
slug = slugify_skill_name(manifest.name)
# Drive-owned files: canonical SKILL.md, every inspectable archive file,
# and the full archive for future restore/export.
# Drive-owned files: canonical SKILL.md and the full archive. The
# archive member tree is preserved in metadata and resolved lazily.
md_tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=skill_md_bytes,
file_binary=package.skill_md_bytes,
mimetype="text/markdown",
filename=_SKILL_MD_NAME,
)
@@ -78,38 +84,14 @@ class SkillStandardizeService:
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=content,
file_binary=package.archive_bytes,
mimetype="application/zip",
filename=_FULL_ARCHIVE_NAME,
)
skill_md_key = f"{slug}/{_SKILL_MD_NAME}"
archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}"
member_items: list[DriveCommitItem] = []
for member_path in sorted(set(manifest.files)):
member_key = f"{slug}/{member_path}"
if member_key in {skill_md_key, archive_key}:
continue
member_bytes = self._package.read_member_bytes(content=content, member_path=member_path)
mimetype = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
member_tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=member_bytes,
mimetype=mimetype,
filename=posixpath.basename(member_path),
)
member_items.append(
DriveCommitItem(
key=member_key,
file_ref=DriveFileRef(kind="tool_file", id=member_tool_file.id),
value_owned_by_drive=True,
)
)
self._drive.commit(
committed_items = self._drive.commit(
tenant_id=tenant_id,
user_id=user_id,
agent_id=agent_id,
@@ -130,23 +112,17 @@ class SkillStandardizeService:
file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id),
value_owned_by_drive=True,
),
*member_items,
],
)
drive_skill = next(
skill
for skill in self._drive.list_skills(tenant_id=tenant_id, agent_id=agent_id)
if skill["skill_md_key"] == skill_md_key
)
self.last_committed_items = committed_items
return {
"skill": {
"name": drive_skill["name"],
"description": drive_skill["description"],
"path": drive_skill["path"],
"skill_md_key": drive_skill["skill_md_key"],
"archive_key": drive_skill["archive_key"],
"name": manifest.name,
"description": manifest.description,
"path": slug,
"skill_md_key": skill_md_key,
"archive_key": archive_key,
},
"manifest": manifest.model_dump(),
}
+34 -3
View File
@@ -18,9 +18,18 @@ from models.agent import (
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
WorkflowNodeJobConfig,
WorkflowPreviousNodeOutputRef,
)
from models.workflow import Workflow
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.prompt_mentions import (
extract_workflow_node_output_selectors,
workflow_previous_node_output_refs_from_selectors,
)
from services.entities.agent_entities import (
ComposerSavePayload,
ComposerSaveStrategy,
@@ -39,10 +48,10 @@ class WorkflowAgentPublishService:
@classmethod
def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]:
"""Return draft graph with persisted Agent node job config projected into node data.
"""Return draft graph with persisted Agent binding fields projected into node data.
Workflow draft graph is the front-end's editing source of truth, while
runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This
runtime/publish reads WorkflowAgentNodeBinding. This
response-only projection keeps reads aligned without writing binding
details back into the stored graph JSON.
"""
@@ -64,6 +73,18 @@ class WorkflowAgentPublishService:
node_data = agent_nodes.get(binding.node_id)
if not isinstance(node_data, dict):
continue
graph_binding = node_data.get(cls._AGENT_BINDING_KEY)
is_pending_inline_graph_binding = (
isinstance(graph_binding, Mapping)
and graph_binding.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value
and (not graph_binding.get("agent_id") or not graph_binding.get("current_snapshot_id"))
)
if not is_pending_inline_graph_binding or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
node_data[cls._AGENT_BINDING_KEY] = {
"binding_type": binding.binding_type.value,
"agent_id": binding.agent_id,
"current_snapshot_id": binding.current_snapshot_id,
}
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
if node_job.workflow_prompt is not None:
node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt
@@ -231,6 +252,10 @@ class WorkflowAgentPublishService:
continue
if not isinstance(binding_payload, Mapping):
raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.")
if binding_payload.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value and (
not binding_payload.get("agent_id") or not binding_payload.get("current_snapshot_id")
):
continue
cls._sync_agent_binding_for_node(
session=session,
draft_workflow=draft_workflow,
@@ -409,6 +434,7 @@ class WorkflowAgentPublishService:
agent_task = node_data.get(cls._AGENT_TASK_KEY)
if isinstance(agent_task, str):
node_job.workflow_prompt = agent_task
node_job.previous_node_output_refs = cls._previous_node_output_refs_from_prompt(agent_task)
declared_outputs_payload = node_data.get(cls._AGENT_DECLARED_OUTPUTS_KEY)
if declared_outputs_payload is not None:
@@ -423,6 +449,11 @@ class WorkflowAgentPublishService:
return node_job
@classmethod
def _previous_node_output_refs_from_prompt(cls, prompt: str) -> list[WorkflowPreviousNodeOutputRef]:
"""Derive persisted refs from the current frontend workflow markers only."""
return workflow_previous_node_output_refs_from_selectors(extract_workflow_node_output_selectors(prompt))
@classmethod
def copy_agent_node_bindings_to_published(
cls,
+347 -28
View File
@@ -19,10 +19,18 @@ ToolFile records (see ``AgentDriveFile``). This service is the control plane:
from __future__ import annotations
import base64
import hashlib
import hmac
import io
import json
import logging
import mimetypes
import os
import re
import time
import urllib.parse
import zipfile
from typing import Any, Literal, TypedDict
from urllib.parse import unquote
@@ -31,6 +39,7 @@ from sqlalchemy import func, select
from sqlalchemy.exc import DataError, SQLAlchemyError
from sqlalchemy.orm import Session
from configs import dify_config
from core.app.file_access.controller import DatabaseFileAccessController
from core.db.session_factory import session_factory
from extensions.ext_storage import storage
@@ -46,6 +55,7 @@ _MAX_KEY_LENGTH = 512
_DRIVE_REF_PREFIX = "agent-"
_SKILL_MD_SUFFIX = "/SKILL.md"
_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE = "agent-drive-archive-member"
class AgentDriveError(Exception):
@@ -365,6 +375,7 @@ class AgentDriveService:
skill_md_key=skill_md_key,
manifest_files=manifest_files,
drive_keys=drive_keys,
archive_available=catalog["archive_key"] in drive_keys if catalog["archive_key"] else False,
)
return {
**catalog,
@@ -598,6 +609,7 @@ class AgentDriveService:
skill_md_key: str,
manifest_files: list[str] | None,
drive_keys: set[str],
archive_available: bool = False,
) -> tuple[list[AgentDriveSkillFileInfo], list[str]]:
warnings: list[str] = []
if manifest_files:
@@ -617,13 +629,14 @@ class AgentDriveService:
if path == _SKILL_ARCHIVE_NAME:
continue
drive_key = f"{skill_path}/{path}"
available_in_drive = drive_key in drive_keys or (archive_available and path != _SKILL_ARCHIVE_NAME)
files.append(
{
"path": path,
"name": path.rsplit("/", 1)[-1],
"type": "file",
"drive_key": drive_key if drive_key in drive_keys else None,
"available_in_drive": drive_key in drive_keys,
"drive_key": drive_key if available_in_drive else None,
"available_in_drive": available_in_drive,
}
)
if "SKILL.md" not in {file["path"] for file in files}:
@@ -844,56 +857,209 @@ class AgentDriveService:
return row
def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str:
if row.file_kind == AgentDriveFileKind.TOOL_FILE:
tool_file = session.scalar(
select(ToolFile).where(ToolFile.id == row.file_id, ToolFile.tenant_id == tenant_id)
)
return self._storage_key_for_ref(
session,
tenant_id=tenant_id,
file_kind=row.file_kind,
file_id=row.file_id,
)
def _storage_key_for_ref(
self,
session: Session,
*,
tenant_id: str,
file_kind: AgentDriveFileKind,
file_id: str,
) -> str:
if file_kind == AgentDriveFileKind.TOOL_FILE:
tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id))
if tool_file is None:
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
return tool_file.file_key
upload_file = session.scalar(
select(UploadFile).where(UploadFile.id == row.file_id, UploadFile.tenant_id == tenant_id)
select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id)
)
if upload_file is None:
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
return upload_file.key
def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
"""Truncated text preview of one drive value (binary-safe, never 500s on size)."""
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
size = row.size
def _archive_member_for_key(
self,
session: Session,
*,
tenant_id: str,
agent_id: str,
key: str,
) -> tuple[AgentDriveFile, str]:
normalized_key = normalize_drive_key(key)
if "/" not in normalized_key:
raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
skill_path, member_path = normalized_key.split("/", 1)
if member_path in {_SKILL_ARCHIVE_NAME, ""}:
raise AgentDriveError("drive_key_not_found", "no archive member for this key", status_code=404)
data = bytearray()
for chunk in storage.load_stream(storage_key):
data.extend(chunk)
if len(data) > self.PREVIEW_MAX_BYTES:
break
truncated = len(data) > self.PREVIEW_MAX_BYTES
sample = bytes(data[: self.PREVIEW_MAX_BYTES])
# Same semantics as the sandbox read endpoint: NUL or undecodable -> binary.
skill_md_key = f"{skill_path}{_SKILL_MD_SUFFIX}"
skill_row = session.scalar(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key == skill_md_key,
AgentDriveFile.is_skill.is_(True),
)
)
if skill_row is None:
raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
metadata = self._parse_skill_metadata(skill_row.key, skill_row.skill_metadata)
manifest_files = {normalize_drive_key(path) for path in (metadata.manifest_files or [])}
if member_path not in manifest_files:
raise AgentDriveError("drive_key_not_found", "archive member is not part of this skill", status_code=404)
archive_row = session.scalar(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key == self._skill_archive_key(skill_md_key),
)
)
if archive_row is None:
raise AgentDriveError("drive_key_not_found", "skill archive is missing", status_code=404)
return archive_row, member_path
def _load_archive_member_bytes(
self,
*,
tenant_id: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
) -> bytes:
member_path = normalize_drive_key(member_path)
with session_factory.create_session() as session:
storage_key = self._storage_key_for_ref(
session,
tenant_id=tenant_id,
file_kind=archive_file_kind,
file_id=archive_file_id,
)
archive_bytes = b"".join(storage.load_stream(storage_key))
try:
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
member = next(
(
info
for info in archive.infolist()
if not info.is_dir() and normalize_drive_key(info.filename) == member_path
),
None,
)
if member is None:
raise AgentDriveError(
"drive_key_not_found", "archive member is missing from the skill archive", status_code=404
)
return archive.read(member)
except zipfile.BadZipFile as exc:
raise AgentDriveError("invalid_skill_archive", "skill archive is not a valid zip", status_code=500) from exc
@classmethod
def _preview_bytes(cls, *, key: str, size: int | None, payload: bytes) -> dict[str, Any]:
truncated = len(payload) > cls.PREVIEW_MAX_BYTES
sample = payload[: cls.PREVIEW_MAX_BYTES]
if b"\x00" in sample:
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
try:
text = sample.decode("utf-8")
except UnicodeDecodeError:
if truncated:
# A multi-byte char may sit on the cut point; retry without the tail.
try:
text = sample[:-3].decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
else:
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text}
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": key, "size": size, "truncated": truncated, "binary": False, "text": text}
def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
"""Truncated text preview of one drive value (binary-safe, never 500s on size)."""
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
try:
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
size = row.size
response_key = row.key
archive_ref: tuple[AgentDriveFile, str] | None = None
except AgentDriveError:
archive_ref = self._archive_member_for_key(
session,
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
)
storage_key = None
size = None
response_key = normalize_drive_key(key)
if archive_ref is not None:
archive_row, member_path = archive_ref
payload = self._load_archive_member_bytes(
tenant_id=tenant_id,
archive_file_kind=archive_row.file_kind,
archive_file_id=archive_row.file_id,
member_path=member_path,
)
return self._preview_bytes(key=response_key, size=len(payload), payload=payload)
data = bytearray()
assert storage_key is not None
for chunk in storage.load_stream(storage_key):
data.extend(chunk)
if len(data) > self.PREVIEW_MAX_BYTES:
break
return self._preview_bytes(key=response_key, size=size, payload=bytes(data))
def preview_archive_member_for_ref(
self,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
) -> dict[str, Any]:
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
payload = self._load_archive_member_bytes(
tenant_id=tenant_id,
archive_file_kind=archive_file_kind,
archive_file_id=archive_file_id,
member_path=member_path,
)
return self._preview_bytes(key=normalize_drive_key(key), size=len(payload), payload=payload)
def download_url(self, *, tenant_id: str, agent_id: str, key: str) -> str:
"""External signed URL for a browser download of one drive value."""
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
try:
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
except AgentDriveError:
archive_row, member_path = self._archive_member_for_key(
session,
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
)
return self.sign_archive_member_url(
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
archive_file_kind=archive_row.file_kind,
archive_file_id=archive_row.file_id,
member_path=member_path,
for_external=True,
as_attachment=True,
)
url = self._resolve_download_url(
tenant_id=tenant_id,
file_kind=row.file_kind,
@@ -905,6 +1071,159 @@ class AgentDriveService:
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
return url
def download_url_archive_member_for_ref(
self,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
for_external: bool = True,
) -> str:
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
return self.sign_archive_member_url(
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
archive_file_kind=archive_file_kind,
archive_file_id=archive_file_id,
member_path=member_path,
for_external=for_external,
as_attachment=True,
)
@staticmethod
def _secret_key() -> bytes:
return dify_config.SECRET_KEY.encode()
@classmethod
def _archive_member_signature_payload(
cls,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
timestamp: str,
nonce: str,
) -> str:
return "|".join(
[
_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE,
tenant_id,
agent_id,
normalize_drive_key(key),
archive_file_kind.value,
archive_file_id,
normalize_drive_key(member_path),
timestamp,
nonce,
]
)
@classmethod
def _sign_archive_member_payload(cls, payload: str) -> str:
digest = hmac.new(cls._secret_key(), payload.encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode()
@classmethod
def sign_archive_member_url(
cls,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
for_external: bool,
as_attachment: bool = False,
) -> str:
base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
payload = cls._archive_member_signature_payload(
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
archive_file_kind=archive_file_kind,
archive_file_id=archive_file_id,
member_path=member_path,
timestamp=timestamp,
nonce=nonce,
)
query = urllib.parse.urlencode(
{
"tenant_id": tenant_id,
"agent_id": agent_id,
"key": normalize_drive_key(key),
"archive_file_kind": archive_file_kind.value,
"archive_file_id": archive_file_id,
"member_path": normalize_drive_key(member_path),
"timestamp": timestamp,
"nonce": nonce,
"sign": cls._sign_archive_member_payload(payload),
"as_attachment": str(as_attachment).lower(),
}
)
return f"{base_url}/files/agent-drive/archive-member?{query}"
@classmethod
def verify_archive_member_signature(
cls,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
timestamp: str,
nonce: str,
sign: str,
) -> bool:
payload = cls._archive_member_signature_payload(
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
archive_file_kind=archive_file_kind,
archive_file_id=archive_file_id,
member_path=member_path,
timestamp=timestamp,
nonce=nonce,
)
if sign != cls._sign_archive_member_payload(payload):
return False
current_time = int(time.time())
return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
def load_archive_member_for_signed_request(
self,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
) -> tuple[bytes, str, str]:
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
payload = self._load_archive_member_bytes(
tenant_id=tenant_id,
archive_file_kind=archive_file_kind,
archive_file_id=archive_file_id,
member_path=member_path,
)
mime_type = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
filename = normalize_drive_key(key).rsplit("/", 1)[-1]
return payload, mime_type, filename
__all__ = [
"AgentDriveError",
+11
View File
@@ -96,6 +96,17 @@ class AppService:
filters.append(App.mode == AppMode.AGENT_CHAT)
elif params.mode == "agent":
filters.append(App.mode == AppMode.AGENT)
filters.append(
sa.exists()
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == App.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
.correlate(App)
)
elif params.mode == "all":
filters.append(App.mode != AppMode.AGENT)
+3 -3
View File
@@ -7,12 +7,12 @@ from typing import Any, Literal, NotRequired, TypedDict
import httpx
from pydantic import TypeAdapter
from sqlalchemy import select
from sqlalchemy.orm import Session, scoped_session
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
from werkzeug.exceptions import InternalServerError
from core.helper.http_client_pooling import get_pooled_http_client
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.helper import RateLimiter
from models import Account, TenantAccountJoin, TenantAccountRole
@@ -363,10 +363,10 @@ class BillingService:
return response.json()
@staticmethod
def is_tenant_owner_or_admin(current_user: Account):
def is_tenant_owner_or_admin(session: Session | scoped_session, current_user: Account):
tenant_id = current_user.current_tenant_id
join: TenantAccountJoin | None = db.session.scalar(
join: TenantAccountJoin | None = session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == current_user.id)
.limit(1)
@@ -125,9 +125,9 @@ class ClearFreePlanTenantExpiredLogs:
)
@classmethod
def process_tenant(cls, flask_app: Flask, tenant_id: str, days: int, batch: int):
def process_tenant(cls, flask_app: Flask, tenant_id: str, days: int, batch: int, session: Session):
with flask_app.app_context():
apps = db.session.scalars(select(App).where(App.tenant_id == tenant_id)).all()
apps = session.scalars(select(App).where(App.tenant_id == tenant_id)).all()
app_ids = [app.id for app in apps]
while True:
with sessionmaker(bind=db.engine, autoflush=False).begin() as session:
@@ -375,7 +375,8 @@ class ClearFreePlanTenantExpiredLogs:
or BillingService.get_info(tenant_id)["subscription"]["plan"] == CloudPlan.SANDBOX
):
# only process sandbox tenant
cls.process_tenant(flask_app, tenant_id, days, batch)
with sessionmaker(db.engine).begin() as session:
cls.process_tenant(flask_app, tenant_id, days, batch, session)
except Exception:
logger.exception("Failed to process tenant %s", tenant_id)
finally:
@@ -1,9 +1,8 @@
from collections.abc import Sequence
from sqlalchemy import or_, select
from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.orm import InstrumentedAttribute, Session, scoped_session
from extensions.ext_database import db
from models.account import Account
from models.credential_permission import CredentialPermission
from models.enums import PermissionEnum
@@ -17,9 +16,11 @@ class CredentialPermissionService:
"""
@classmethod
def get_partial_member_list(cls, credential_id: str, credential_type: str) -> Sequence[str]:
def get_partial_member_list(
cls, session: Session | scoped_session, credential_id: str, credential_type: str
) -> Sequence[str]:
"""Return account_ids that have partial-member access to a credential."""
return db.session.scalars(
return session.scalars(
select(CredentialPermission.account_id).where(
CredentialPermission.credential_id == credential_id,
CredentialPermission.credential_type == credential_type,
+9 -8
View File
@@ -248,7 +248,7 @@ class DatasetService:
def get_datasets(
page,
per_page,
session: scoped_session | Session | None = None,
session: scoped_session | Session,
tenant_id=None,
user=None,
search=None,
@@ -257,7 +257,7 @@ class DatasetService:
accessible_dataset_ids: list[str] | None = None,
include_own_datasets: bool = False,
):
session = session or db.session
"""Return visible datasets for a tenant, using the injected session for auxiliary permission lookups."""
query = select(Dataset).where(Dataset.tenant_id == tenant_id).order_by(Dataset.created_at.desc(), Dataset.id)
if dify_config.RBAC_ENABLED and accessible_dataset_ids is not None:
@@ -268,7 +268,7 @@ class DatasetService:
if user:
# get permitted dataset ids
dataset_permission = db.session.scalars(
dataset_permission = session.scalars(
select(DatasetPermission).where(
DatasetPermission.account_id == user.id, DatasetPermission.tenant_id == tenant_id
)
@@ -652,7 +652,7 @@ class DatasetService:
raise ValueError("Dataset name already exists")
# Verify user has permission to update this dataset
DatasetService.check_dataset_permission(dataset, user)
DatasetService.check_dataset_permission(dataset, user, db.session)
# Handle external dataset updates
if dataset.provider == "external":
@@ -1311,7 +1311,7 @@ class DatasetService:
if dataset is None:
return False
DatasetService.check_dataset_permission(dataset, user)
DatasetService.check_dataset_permission(dataset, user, db.session)
dataset_was_deleted.send(dataset)
@@ -1325,7 +1325,8 @@ class DatasetService:
return db.session.execute(stmt).scalar_one()
@staticmethod
def check_dataset_permission(dataset, user):
def check_dataset_permission(dataset, user, session: scoped_session | Session):
"""Validate dataset access for a user, using the injected session for partial-member lookups."""
if dataset.tenant_id != user.current_tenant_id:
logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id)
raise NoPermissionError("You do not have permission to access this dataset.")
@@ -1336,7 +1337,7 @@ class DatasetService:
if dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
# For partial team permission, user needs explicit permission or be the maintainer.
if dataset.maintainer != user.id:
user_permission = db.session.scalar(
user_permission = session.scalar(
select(DatasetPermission)
.where(DatasetPermission.dataset_id == dataset.id, DatasetPermission.account_id == user.id)
.limit(1)
@@ -1728,7 +1729,7 @@ class DocumentService:
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except NoPermissionError as e:
raise Forbidden(str(e))
+4
View File
@@ -31,6 +31,10 @@ class ComposerSoulLockPayload(BaseModel):
unlocked_from_version_id: str | None = None
class WorkflowAgentComposerQuery(BaseModel):
snapshot_id: str | None = Field(default=None, max_length=255)
class ComposerSavePayload(BaseModel):
variant: ComposerVariant
binding: ComposerBindingPayload | None = None
@@ -173,14 +173,6 @@ class InnerKnowledgeRetrieveRequest(BaseModel):
class InnerKnowledgeRetrieveUsage(ResponseModel):
"""Serialized LLM usage payload returned by dataset retrieval."""
model_config = ConfigDict(
from_attributes=True,
extra="forbid",
populate_by_name=True,
serialize_by_alias=True,
protected_namespaces=(),
)
prompt_tokens: int
completion_tokens: int
total_tokens: int
+21 -2
View File
@@ -7,10 +7,13 @@ from sqlalchemy import or_, select
from constants import HIDDEN_VALUE
from core.entities.provider_configuration import ProviderConfiguration
from core.helper import encrypter
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
from core.helper.model_provider_cache import (
ProviderCredentialsCache,
ProviderCredentialsCacheType,
)
from core.model_manager import LBModelManager
from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly, create_plugin_provider_manager
from core.provider_manager import ProviderManager
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.entities.provider_entities import (
@@ -313,6 +316,10 @@ class ModelLoadBalancingService:
)
db.session.add(inherit_config)
db.session.commit()
ProviderManager.invalidate_configurations_cache(
tenant_id,
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
)
return inherit_config
@@ -434,6 +441,10 @@ class ModelLoadBalancingService:
load_balancing_config.enabled = enabled
load_balancing_config.updated_at = naive_utc_now()
db.session.commit()
ProviderManager.invalidate_configurations_cache(
tenant_id,
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
)
self._clear_credentials_cache(tenant_id, config_id)
else:
@@ -487,12 +498,20 @@ class ModelLoadBalancingService:
db.session.add(load_balancing_model_config)
db.session.commit()
ProviderManager.invalidate_configurations_cache(
tenant_id,
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
)
# get deleted config ids
deleted_config_ids = set(current_load_balancing_configs_dict.keys()) - updated_config_ids
for config_id in deleted_config_ids:
db.session.delete(current_load_balancing_configs_dict[config_id])
db.session.commit()
ProviderManager.invalidate_configurations_cache(
tenant_id,
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
)
self._clear_credentials_cache(tenant_id, config_id)
@@ -8,6 +8,11 @@ Archive V2 writes bundle-level Parquet objects. A bundle contains many workflow
Bundle metadata lives in the object-store manifest instead of a database table, so archive/delete/restore does not move
the large-table retention problem into another OLTP table.
Archive campaigns should use fixed absolute UTC windows for every tenant-prefix/shard execution. Relative windows are
evaluated at process start and are not safe for multi-day rollout because each command would scan a different window.
Per-shard `index.json` objects are derived from bundle manifests and provide run-level idempotency without adding a
database claim table; bundle manifests remain the source of truth when an index must be rebuilt.
Archived tables:
- workflow_runs
- workflow_app_logs
@@ -25,9 +30,11 @@ import json
import logging
import time
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, TypedDict
from threading import Lock
from typing import Any, NotRequired, TypedDict, cast
import click
import pyarrow as pa
@@ -59,6 +66,7 @@ from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWo
from services.billing_service import BillingService
from services.retention.workflow_run.constants import (
ARCHIVE_BUNDLE_FORMAT,
ARCHIVE_BUNDLE_INDEX_NAME,
ARCHIVE_BUNDLE_MANIFEST_NAME,
ARCHIVE_BUNDLE_SCHEMA_VERSION,
)
@@ -90,10 +98,24 @@ class ArchiveManifestDict(TypedDict):
min_run_id: str
max_run_id: str
archived_at: str
campaign_id: str
archive_window_start: str | None
archive_window_end: str
run_shard: str
tables: dict[str, TableStatsManifestEntry]
run_ids: list[str]
class ArchiveBundleIndexDict(TypedDict):
schema_version: str
archive_format: str
object_prefix: str
updated_at: str
manifest_keys: list[str]
run_ids: list[str]
campaign_ids: NotRequired[list[str]]
@dataclass(frozen=True)
class ArchiveBundleIdentity:
"""Stable identity and object prefix for one V2 archive bundle."""
@@ -127,6 +149,7 @@ class ArchiveResult:
object_prefix: str
success: bool
run_count: int = 0
skipped_run_count: int = 0
tables: list[TableStats] = field(default_factory=list)
object_size_bytes: int = 0
skipped: bool = False
@@ -192,11 +215,14 @@ class WorkflowRunArchiver:
tenant_prefixes: list[str]
run_shard_index: int | None
run_shard_total: int | None
campaign_id: str
_archive_index_cache: dict[str, set[str]]
_archive_index_cache_lock: Lock
def __init__(
self,
days: int = 90,
batch_size: int = 100,
batch_size: int = 10000,
start_from: datetime.datetime | None = None,
end_before: datetime.datetime | None = None,
workers: int = 1,
@@ -238,10 +264,10 @@ class WorkflowRunArchiver:
if start_from or end_before:
if start_from is None or end_before is None:
raise ValueError("start_from and end_before must be provided together")
if start_from >= end_before:
self.start_from = self._normalize_utc_datetime(start_from)
self.end_before = self._normalize_utc_datetime(end_before)
if self.start_from >= self.end_before:
raise ValueError("start_from must be earlier than end_before")
self.start_from = start_from.replace(tzinfo=datetime.UTC)
self.end_before = end_before.replace(tzinfo=datetime.UTC)
else:
self.start_from = None
self.end_before = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days)
@@ -259,6 +285,9 @@ class WorkflowRunArchiver:
raise ValueError("run_shard_index must be between 0 and run_shard_total - 1")
self.run_shard_index = run_shard_index
self.run_shard_total = run_shard_total
self.campaign_id = self._build_campaign_id()
self._archive_index_cache = {}
self._archive_index_cache_lock = Lock()
self.limit = limit
self.dry_run = dry_run
self.delete_after_archive = delete_after_archive
@@ -322,24 +351,23 @@ class WorkflowRunArchiver:
if not runs_to_process:
continue
for bundle_runs in self._group_runs_for_bundles(runs_to_process):
summary.total_bundles_processed += 1
with session_maker() as session:
result = self._archive_bundle(session, storage, bundle_runs)
bundle_groups = self._group_runs_for_bundles(runs_to_process)
summary.total_bundles_processed += len(bundle_groups)
for result in self._archive_bundle_groups(session_maker, storage, bundle_groups):
attempted_count += result.run_count + result.skipped_run_count
summary.runs_skipped += result.skipped_run_count
if result.skipped:
attempted_count += result.run_count
summary.bundles_skipped += 1
summary.runs_skipped += result.run_count
click.echo(
click.style(
f"Skipped bundle {result.bundle_id} (tenant={result.tenant_id}, "
f"runs={result.run_count}, reason={result.error or 'already handled'})",
f"runs={result.run_count}, skipped_runs={result.skipped_run_count}, "
f"reason={result.error or 'already handled'})",
fg="yellow",
)
)
elif result.success:
attempted_count += result.run_count
summary.bundles_archived += 1
summary.runs_archived += result.run_count
self._merge_result_stats(summary, result)
@@ -347,15 +375,14 @@ class WorkflowRunArchiver:
click.style(
f"{'[DRY RUN] Would archive' if self.dry_run else 'Archived'} "
f"bundle {result.bundle_id} (tenant={result.tenant_id}, runs={result.run_count}, "
f"tables={len(result.tables)}, object_size_bytes={result.object_size_bytes}, "
f"time={result.elapsed_time:.2f}s)",
f"skipped_runs={result.skipped_run_count}, tables={len(result.tables)}, "
f"object_size_bytes={result.object_size_bytes}, time={result.elapsed_time:.2f}s)",
fg="green",
)
)
if self.dry_run:
self._echo_table_estimates(result.tables)
else:
attempted_count += result.run_count
summary.bundles_failed += 1
summary.runs_failed += result.run_count
click.echo(
@@ -493,6 +520,35 @@ class WorkflowRunArchiver:
return paid
def _archive_bundle_groups(
self,
session_maker: sessionmaker[Session],
storage: ArchiveStorage | None,
bundle_groups: Sequence[Sequence[WorkflowRun]],
) -> list[ArchiveResult]:
"""Archive grouped bundles, optionally in parallel."""
if not bundle_groups:
return []
if self.workers == 1 or len(bundle_groups) == 1:
results: list[ArchiveResult] = []
for bundle_runs in bundle_groups:
with session_maker() as session:
results.append(self._archive_bundle(session, storage, bundle_runs))
return results
results = []
max_workers = min(self.workers, len(bundle_groups))
def archive_in_worker(bundle_runs: Sequence[WorkflowRun]) -> ArchiveResult:
with session_maker() as session:
return self._archive_bundle(session, storage, bundle_runs)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [executor.submit(archive_in_worker, bundle_runs) for bundle_runs in bundle_groups]
for future in as_completed(futures):
results.append(future.result())
return results
def _archive_bundle(
self,
session: Session,
@@ -503,6 +559,7 @@ class WorkflowRunArchiver:
if not runs:
raise ValueError("runs must not be empty")
start_time = time.time()
original_run_count = len(runs)
identity = self._build_bundle_identity(runs)
result = ArchiveResult(
bundle_id=identity.bundle_id,
@@ -517,12 +574,36 @@ class WorkflowRunArchiver:
if storage is None:
raise ArchiveStorageNotConfiguredError("Archive storage not configured")
if storage.object_exists(self._get_manifest_object_key(identity)):
self._write_bundle_index(storage, identity)
result.success = True
result.skipped = True
result.error = "bundle already archived"
result.elapsed_time = time.time() - start_time
return result
archived_run_ids = self._load_archived_run_ids_for_identity(storage, identity)
runs = [run for run in runs if run.id not in archived_run_ids]
result.skipped_run_count = original_run_count - len(runs)
if not runs:
result.run_count = 0
result.success = True
result.skipped = True
result.error = "all runs already archived in shard index"
result.elapsed_time = time.time() - start_time
return result
identity = self._build_bundle_identity(runs)
result.bundle_id = identity.bundle_id
result.object_prefix = identity.object_prefix
result.run_count = len(runs)
if storage.object_exists(self._get_manifest_object_key(identity)):
self._write_bundle_index(storage, identity)
result.success = True
result.skipped = True
result.error = "filtered bundle already archived"
result.elapsed_time = time.time() - start_time
return result
locked_runs = self._lock_runs_for_archive(session, [run.id for run in runs])
if len(locked_runs) != len(runs):
result.success = True
@@ -547,6 +628,7 @@ class WorkflowRunArchiver:
for table_name, payload in table_payloads.items():
storage.put_object(self._get_table_object_key(identity, table_name), payload)
storage.put_object(self._get_manifest_object_key(identity), manifest_data)
self._merge_bundle_manifest_into_index(storage, identity, [run.id for run in runs])
session.commit()
logger.info(
@@ -596,49 +678,68 @@ class WorkflowRunArchiver:
session: Session,
runs: Sequence[WorkflowRun],
) -> dict[str, list[dict[str, Any]]]:
"""Extract all archived table rows for a bundle."""
"""Extract archived rows using Core mappings to avoid ORM hydration on large retention batches."""
run_ids = [run.id for run in runs]
table_data: dict[str, list[dict[str, Any]]] = {}
table_data["workflow_runs"] = [self._row_to_dict(run) for run in runs]
app_logs = list(session.scalars(select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids))))
table_data["workflow_app_logs"] = [self._row_to_dict(row) for row in app_logs]
node_exec_records = list(
session.scalars(
select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.workflow_run_id.in_(run_ids))
)
table_data["workflow_app_logs"] = self._select_rows_by_column(
session,
WorkflowAppLog,
WorkflowAppLog.workflow_run_id,
run_ids,
)
node_exec_ids = [record.id for record in node_exec_records]
offload_records = []
if node_exec_ids:
offload_records = list(
session.scalars(
select(WorkflowNodeExecutionOffload).where(
WorkflowNodeExecutionOffload.node_execution_id.in_(node_exec_ids)
)
)
)
table_data["workflow_node_executions"] = [self._row_to_dict(row) for row in node_exec_records]
table_data["workflow_node_execution_offload"] = [self._row_to_dict(row) for row in offload_records]
pause_records = list(session.scalars(select(WorkflowPause).where(WorkflowPause.workflow_run_id.in_(run_ids))))
pause_ids = [pause.id for pause in pause_records]
pause_reason_records = []
if pause_ids:
pause_reason_records = list(
session.scalars(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids)))
)
table_data["workflow_pauses"] = [self._row_to_dict(row) for row in pause_records]
table_data["workflow_pause_reasons"] = [self._row_to_dict(row) for row in pause_reason_records]
node_exec_records = self._select_rows_by_column(
session,
WorkflowNodeExecutionModel,
WorkflowNodeExecutionModel.workflow_run_id,
run_ids,
)
node_exec_ids = [str(record["id"]) for record in node_exec_records]
table_data["workflow_node_executions"] = node_exec_records
table_data["workflow_node_execution_offload"] = self._select_rows_by_column(
session,
WorkflowNodeExecutionOffload,
WorkflowNodeExecutionOffload.node_execution_id,
node_exec_ids,
)
trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(session)
trigger_records: list[WorkflowTriggerLog] = []
for run_id in run_ids:
trigger_records.extend(trigger_repo.list_by_run_id(run_id))
table_data["workflow_trigger_logs"] = [self._row_to_dict(row) for row in trigger_records]
pause_records = self._select_rows_by_column(
session,
WorkflowPause,
WorkflowPause.workflow_run_id,
run_ids,
)
pause_ids = [str(record["id"]) for record in pause_records]
table_data["workflow_pauses"] = pause_records
table_data["workflow_pause_reasons"] = self._select_rows_by_column(
session,
WorkflowPauseReason,
WorkflowPauseReason.pause_id,
pause_ids,
)
table_data["workflow_trigger_logs"] = self._select_rows_by_column(
session,
WorkflowTriggerLog,
WorkflowTriggerLog.workflow_run_id,
run_ids,
)
return table_data
@staticmethod
def _select_rows_by_column(
session: Session,
model: Any,
column: Any,
values: Sequence[str],
) -> list[dict[str, Any]]:
if not values:
return []
stmt = select(*model.__table__.columns).where(column.in_(values))
return [dict(row) for row in session.execute(stmt).mappings().all()]
@staticmethod
def _row_to_dict(row: Any) -> dict[str, Any]:
mapper = inspect(row).mapper
@@ -690,6 +791,9 @@ class WorkflowRunArchiver:
for stat in table_stats
}
sorted_runs = sorted(runs, key=lambda run: (run.created_at, run.id))
end_before = self.end_before
if end_before is None:
raise ValueError("archive window end must be set")
return ArchiveManifestDict(
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
archive_format=ARCHIVE_BUNDLE_FORMAT,
@@ -707,6 +811,10 @@ class WorkflowRunArchiver:
min_run_id=min(run.id for run in runs),
max_run_id=max(run.id for run in runs),
archived_at=datetime.datetime.now(datetime.UTC).isoformat(),
campaign_id=self.campaign_id,
archive_window_start=self._format_window_datetime(self.start_from),
archive_window_end=end_before.isoformat(),
run_shard=identity.shard,
tables=tables,
run_ids=[run.id for run in sorted_runs],
)
@@ -773,6 +881,158 @@ class WorkflowRunArchiver:
return "00-of-01"
return f"{self.run_shard_index:02d}-of-{self.run_shard_total:02d}"
def _load_archived_run_ids_for_identity(
self,
storage: ArchiveStorage,
identity: ArchiveBundleIdentity,
) -> set[str]:
index_key = self._get_index_object_key(identity)
with self._archive_index_cache_lock:
cached_run_ids = self._archive_index_cache.get(index_key)
if cached_run_ids is not None:
return set(cached_run_ids)
if storage.object_exists(index_key):
index = self._load_bundle_index(storage, identity)
else:
index = self._write_bundle_index(storage, identity)
run_ids = set(index["run_ids"])
with self._archive_index_cache_lock:
self._archive_index_cache[index_key] = set(run_ids)
return run_ids
def _load_bundle_index(
self,
storage: ArchiveStorage,
identity: ArchiveBundleIdentity,
) -> ArchiveBundleIndexDict:
index_key = self._get_index_object_key(identity)
payload = storage.get_object(index_key)
loaded = json.loads(payload)
if not isinstance(loaded, dict):
raise ValueError(f"archive index must be an object: {index_key}")
index = cast(ArchiveBundleIndexDict, loaded)
expected_prefix = self._get_shard_object_prefix(identity)
if index["schema_version"] != ARCHIVE_BUNDLE_SCHEMA_VERSION:
raise ValueError(f"unsupported archive index schema_version: {index['schema_version']}")
if index["archive_format"] != ARCHIVE_BUNDLE_FORMAT:
raise ValueError(f"unsupported archive index archive_format: {index['archive_format']}")
if index["object_prefix"] != expected_prefix:
raise ValueError("archive index object_prefix does not match shard prefix")
return index
def _write_bundle_index(
self,
storage: ArchiveStorage,
identity: ArchiveBundleIdentity,
) -> ArchiveBundleIndexDict:
index = self._build_bundle_index(storage, identity)
storage.put_object(self._get_index_object_key(identity), json.dumps(index, indent=2).encode("utf-8"))
with self._archive_index_cache_lock:
self._archive_index_cache[self._get_index_object_key(identity)] = set(index["run_ids"])
return index
def _merge_bundle_manifest_into_index(
self,
storage: ArchiveStorage,
identity: ArchiveBundleIdentity,
run_ids: Sequence[str],
) -> ArchiveBundleIndexDict:
index_key = self._get_index_object_key(identity)
if storage.object_exists(index_key):
index = self._load_bundle_index(storage, identity)
else:
index = self._build_bundle_index(storage, identity)
manifest_keys = sorted(set(index["manifest_keys"]) | {self._get_manifest_object_key(identity)})
indexed_run_ids = sorted(set(index["run_ids"]) | set(run_ids))
campaign_id_set: set[str] = set(index.get("campaign_ids", []))
campaign_id_set.add(self.campaign_id)
campaign_ids = sorted(campaign_id_set)
updated_index = ArchiveBundleIndexDict(
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
archive_format=ARCHIVE_BUNDLE_FORMAT,
object_prefix=self._get_shard_object_prefix(identity),
updated_at=datetime.datetime.now(datetime.UTC).isoformat(),
manifest_keys=manifest_keys,
run_ids=indexed_run_ids,
campaign_ids=campaign_ids,
)
storage.put_object(index_key, json.dumps(updated_index, indent=2).encode("utf-8"))
with self._archive_index_cache_lock:
self._archive_index_cache[index_key] = set(indexed_run_ids)
return updated_index
def _build_bundle_index(
self,
storage: ArchiveStorage,
identity: ArchiveBundleIdentity,
) -> ArchiveBundleIndexDict:
shard_prefix = self._get_shard_object_prefix(identity)
manifest_keys = sorted(
key for key in storage.list_objects(shard_prefix) if key.endswith(f"/{ARCHIVE_BUNDLE_MANIFEST_NAME}")
)
run_ids: set[str] = set()
campaign_ids: set[str] = set()
for manifest_key in manifest_keys:
manifest_payload = storage.get_object(manifest_key)
manifest = json.loads(manifest_payload)
if not isinstance(manifest, dict):
raise ValueError(f"archive manifest must be an object: {manifest_key}")
if manifest.get("schema_version") != ARCHIVE_BUNDLE_SCHEMA_VERSION:
raise ValueError(
f"unsupported bundle schema_version in {manifest_key}: {manifest.get('schema_version')}"
)
if manifest.get("archive_format") != ARCHIVE_BUNDLE_FORMAT:
raise ValueError(
f"unsupported bundle archive_format in {manifest_key}: {manifest.get('archive_format')}"
)
manifest_run_ids = manifest.get("run_ids")
if not isinstance(manifest_run_ids, list):
raise ValueError(f"manifest run_ids must be a list: {manifest_key}")
run_ids.update(str(run_id) for run_id in manifest_run_ids)
campaign_id = manifest.get("campaign_id")
if isinstance(campaign_id, str):
campaign_ids.add(campaign_id)
return ArchiveBundleIndexDict(
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
archive_format=ARCHIVE_BUNDLE_FORMAT,
object_prefix=shard_prefix,
updated_at=datetime.datetime.now(datetime.UTC).isoformat(),
manifest_keys=manifest_keys,
run_ids=sorted(run_ids),
campaign_ids=sorted(campaign_ids),
)
@staticmethod
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
if value.tzinfo is None:
return value.replace(tzinfo=datetime.UTC)
return value.astimezone(datetime.UTC)
def _build_campaign_id(self) -> str:
start = self._format_window_datetime(self.start_from) or "unbounded"
end = self._format_window_datetime(self.end_before)
return f"{start}_{end}"
@staticmethod
def _format_window_datetime(value: datetime.datetime | None) -> str | None:
if value is None:
return None
normalized = WorkflowRunArchiver._normalize_utc_datetime(value)
return normalized.isoformat().replace("+00:00", "Z")
@staticmethod
def _get_shard_object_prefix(identity: ArchiveBundleIdentity) -> str:
return (
f"workflow-runs/v2/tenant_prefix={identity.tenant_prefix}/tenant_id={identity.tenant_id}/"
f"year={identity.year:04d}/month={identity.month:02d}/shard={identity.shard}"
)
@classmethod
def _get_index_object_key(cls, identity: ArchiveBundleIdentity) -> str:
return f"{cls._get_shard_object_prefix(identity)}/{ARCHIVE_BUNDLE_INDEX_NAME}"
@staticmethod
def _get_table_object_key(identity: ArchiveBundleIdentity, table_name: str) -> str:
return f"{identity.object_prefix}/{table_name}.parquet"
@@ -4,6 +4,7 @@ ARCHIVE_BUNDLE_NAME = f"archive.v{ARCHIVE_SCHEMA_VERSION}.zip"
ARCHIVE_BUNDLE_SCHEMA_VERSION = "2.0"
ARCHIVE_BUNDLE_FORMAT = "parquet"
ARCHIVE_BUNDLE_MANIFEST_NAME = "manifest.json"
ARCHIVE_BUNDLE_INDEX_NAME = "index.json"
ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME = "_DELETE_STARTED"
ARCHIVE_BUNDLE_DELETED_MARKER_NAME = "_DELETED"
ARCHIVE_BUNDLE_RESTORE_STARTED_MARKER_NAME = "_RESTORE_STARTED"
@@ -427,7 +427,7 @@ class BuiltinToolManageService:
if vis_str == "partial_members":
credential_entity.partial_member_list = list(
CredentialPermissionService.get_partial_member_list(
provider.id, CredPermType.BUILTIN_TOOL_PROVIDER
db.session, provider.id, CredPermType.BUILTIN_TOOL_PROVIDER
)
)
if provider.id in borrowed_ids:
@@ -1,7 +1,7 @@
import datetime
import json
import uuid
from unittest.mock import MagicMock, patch
from unittest.mock import ANY, MagicMock, patch
import pyarrow as pa
import pyarrow.parquet as pq
@@ -14,6 +14,24 @@ from services.retention.workflow_run.archive_paid_plan_workflow_run import (
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_FORMAT, ARCHIVE_BUNDLE_SCHEMA_VERSION
class FakeArchiveStorage:
def __init__(self, objects: dict[str, bytes] | None = None):
self.objects = objects or {}
def object_exists(self, key: str) -> bool:
return key in self.objects
def get_object(self, key: str) -> bytes:
return self.objects[key]
def put_object(self, key: str, data: bytes) -> str:
self.objects[key] = data
return "checksum"
def list_objects(self, prefix: str) -> list[str]:
return sorted(key for key in self.objects if key.startswith(prefix))
class TestWorkflowRunArchiverInit:
def test_start_from_without_end_before_raises(self):
with pytest.raises(ValueError, match="start_from and end_before must be provided together"):
@@ -162,7 +180,9 @@ class TestBuildArchiveBundle:
class TestGenerateManifest:
def test_manifest_structure(self):
archiver = WorkflowRunArchiver(days=90)
start = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC)
end = datetime.datetime(2025, 4, 1, tzinfo=datetime.UTC)
archiver = WorkflowRunArchiver(start_from=start, end_before=end, run_shard_index=1, run_shard_total=4)
from services.retention.workflow_run.archive_paid_plan_workflow_run import TableStats
run = MagicMock()
@@ -197,6 +217,10 @@ class TestGenerateManifest:
assert manifest["workflow_run_count"] == 1
assert manifest["workflow_node_execution_count"] == 2
assert manifest["run_ids"] == [run.id]
assert manifest["campaign_id"] == "2025-01-01T00:00:00Z_2025-04-01T00:00:00Z"
assert manifest["archive_window_start"] == "2025-01-01T00:00:00Z"
assert manifest["archive_window_end"] == "2025-04-01T00:00:00Z"
assert manifest["run_shard"] == "01-of-04"
assert "tables" in manifest
assert manifest["tables"]["workflow_runs"]["row_count"] == 1
assert manifest["tables"]["workflow_runs"]["checksum"] == "abc123"
@@ -328,6 +352,21 @@ class TestDryRunArchive:
class TestArchiveRunIdempotency:
def _index_payload(self, archiver: WorkflowRunArchiver, run_ids: list[str], run) -> tuple[str, bytes]:
identity = archiver._build_bundle_identity([run])
index_key = archiver._get_index_object_key(identity)
payload = json.dumps(
{
"schema_version": ARCHIVE_BUNDLE_SCHEMA_VERSION,
"archive_format": ARCHIVE_BUNDLE_FORMAT,
"object_prefix": archiver._get_shard_object_prefix(identity),
"updated_at": "2025-03-15T00:00:00Z",
"manifest_keys": [],
"run_ids": run_ids,
}
).encode()
return index_key, payload
def test_locked_bundle_is_skipped(self):
archiver = WorkflowRunArchiver(days=90)
run = MagicMock()
@@ -360,3 +399,47 @@ class TestArchiveRunIdempotency:
assert result.success is True
assert result.skipped is True
assert result.error == "bundle already archived"
def test_index_skips_all_already_archived_runs(self):
archiver = WorkflowRunArchiver(days=90)
run = MagicMock()
run.id = "run-1"
run.tenant_id = "tenant-1"
run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
index_key, index_payload = self._index_payload(archiver, ["run-1"], run)
storage = FakeArchiveStorage({index_key: index_payload})
result = archiver._archive_bundle(MagicMock(), storage, [run])
assert result.success is True
assert result.skipped is True
assert result.run_count == 0
assert result.skipped_run_count == 1
assert result.error == "all runs already archived in shard index"
def test_index_filters_duplicate_runs_before_archive(self):
archiver = WorkflowRunArchiver(days=90)
archived_run = MagicMock()
archived_run.id = "run-1"
archived_run.tenant_id = "tenant-1"
archived_run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
new_run = MagicMock()
new_run.id = "run-2"
new_run.tenant_id = "tenant-1"
new_run.created_at = datetime.datetime(2025, 3, 15, 11, 0, 0)
index_key, index_payload = self._index_payload(archiver, ["run-1"], archived_run)
storage = FakeArchiveStorage({index_key: index_payload})
with (
patch.object(archiver, "_lock_runs_for_archive", return_value=[new_run]) as lock_runs,
patch.object(archiver, "_extract_bundle_data", return_value={"workflow_runs": [{"id": "run-2"}]}),
):
result = archiver._archive_bundle(MagicMock(), storage, [archived_run, new_run])
assert result.success is True
assert result.skipped is False
assert result.run_count == 1
assert result.skipped_run_count == 1
lock_runs.assert_called_once_with(ANY, ["run-2"])
manifest_keys = [key for key in storage.objects if key.endswith("/manifest.json")]
assert len(manifest_keys) == 1
@@ -343,8 +343,13 @@ class TestSiteEndpoints:
return flask_app_with_containers
def test_site_response_structure(self):
payload = AppSiteUpdatePayload(title="My Site", description="Test site")
payload = AppSiteUpdatePayload(
title="My Site",
description="Test site",
input_placeholder="Ask me anything",
)
assert payload.title == "My Site"
assert payload.input_placeholder == "Ask me anything"
def test_site_default_language_validation(self):
payload = AppSiteUpdatePayload(default_language="en-US")
@@ -365,6 +370,7 @@ class TestSiteEndpoints:
site.customize_domain = None
site.copyright = None
site.privacy_policy = None
site.input_placeholder = None
site.custom_disclaimer = ""
site.customize_token_strategy = "not_allow"
site.prompt_public = False
@@ -377,11 +383,13 @@ class TestSiteEndpoints:
)
monkeypatch.setattr(site_module, "naive_utc_now", lambda: "now")
with app.test_request_context("/", json={"title": "My Site"}):
with app.test_request_context("/", json={"title": "My Site", "input_placeholder": "Ask me anything"}):
result = method(api, SimpleNamespace(id="u1"), app_model=SimpleNamespace(id="app-1"))
assert isinstance(result, dict)
assert result["title"] == "My Site"
assert result["input_placeholder"] == "Ask me anything"
assert site.input_placeholder == "Ask me anything"
def test_app_site_access_token_reset(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
api = site_module.AppSiteAccessTokenReset()
@@ -398,6 +406,7 @@ class TestSiteEndpoints:
site.customize_domain = None
site.copyright = None
site.privacy_policy = None
site.input_placeholder = None
site.custom_disclaimer = ""
site.customize_token_strategy = "not_allow"
site.prompt_public = False
@@ -222,7 +222,7 @@ def test_get_human_input_form_resolves_runtime_select_options(
)
def mock_get_features(tenant_id: str, exclude_vector_space: bool = False) -> FeatureModel:
features = FeatureModel(can_replace_logo=True)
features = FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True)
return features
monkeypatch.setattr(
@@ -13,6 +13,7 @@ from werkzeug.exceptions import Forbidden
from controllers.web.site import AppSiteApi, AppSiteInfo
from models import Tenant, TenantStatus
from models.model import App, AppMode, CustomizeTokenStrategy, Site
from services.feature_service import FeatureModel
@pytest.fixture
@@ -51,6 +52,7 @@ def _create_site(db_session: Session, app_id: str) -> Site:
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
input_placeholder="Ask the app",
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
code=f"code-{app_id[-6:]}",
prompt_public=False,
@@ -70,7 +72,7 @@ class TestAppSiteApi:
app_model = _create_app(db_session_with_containers, tenant.id)
_create_site(db_session_with_containers, app_model.id)
end_user = SimpleNamespace(id="eu-1")
mock_features.return_value = SimpleNamespace(can_replace_logo=False)
mock_features.return_value = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True)
with app.test_request_context("/site"):
result = AppSiteApi().get(app_model, end_user)
@@ -78,6 +80,7 @@ class TestAppSiteApi:
assert result["app_id"] == app_model.id
assert result["plan"] == "basic"
assert result["enable_site"] is True
assert result["site"]["input_placeholder"] == "Ask the app"
def test_missing_site_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None:
app.config["RESTX_MASK_HEADER"] = "X-Fields"
@@ -98,7 +101,7 @@ class TestAppSiteApi:
app_model = _create_app(db_session_with_containers, tenant.id)
_create_site(db_session_with_containers, app_model.id)
end_user = SimpleNamespace(id="eu-1")
mock_features.return_value = SimpleNamespace(can_replace_logo=False)
mock_features.return_value = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True)
with app.test_request_context("/site"):
with pytest.raises(Forbidden):
@@ -417,7 +417,7 @@ class TestBillingServiceIsTenantOwnerOrAdmin:
account, _ = self._create_account_with_tenant_role(db_session_with_containers, TenantAccountRole.EDITOR)
with pytest.raises(ValueError, match="Only team owner or team admin can perform this action"):
BillingService.is_tenant_owner_or_admin(account)
BillingService.is_tenant_owner_or_admin(db_session_with_containers, account)
def test_is_tenant_owner_or_admin_dataset_operator_raises_error(self, db_session_with_containers: Session) -> None:
"""is_tenant_owner_or_admin raises ValueError for DATASET_OPERATOR role."""
@@ -426,4 +426,4 @@ class TestBillingServiceIsTenantOwnerOrAdmin:
)
with pytest.raises(ValueError, match="Only team owner or team admin can perform this action"):
BillingService.is_tenant_owner_or_admin(account)
BillingService.is_tenant_owner_or_admin(db_session_with_containers, account)
@@ -410,7 +410,7 @@ class TestDatasetServiceCheckDatasetPermission:
)
with pytest.raises(NoPermissionError):
DatasetService.check_dataset_permission(dataset, other_user)
DatasetService.check_dataset_permission(dataset, other_user, db_session_with_containers)
def test_check_dataset_permission_owner_can_access_any_dataset(self, db_session_with_containers: Session):
"""Test that tenant owners can access any dataset regardless of permission level."""
@@ -423,7 +423,7 @@ class TestDatasetServiceCheckDatasetPermission:
tenant.id, creator.id, permission=DatasetPermissionEnum.ONLY_ME
)
DatasetService.check_dataset_permission(dataset, owner)
DatasetService.check_dataset_permission(dataset, owner, db_session_with_containers)
def test_check_dataset_permission_only_me_creator_can_access(self, db_session_with_containers: Session):
"""Test ONLY_ME permission allows only the dataset creator to access."""
@@ -433,7 +433,7 @@ class TestDatasetServiceCheckDatasetPermission:
tenant.id, creator.id, permission=DatasetPermissionEnum.ONLY_ME
)
DatasetService.check_dataset_permission(dataset, creator)
DatasetService.check_dataset_permission(dataset, creator, db_session_with_containers)
def test_check_dataset_permission_only_me_others_cannot_access(self, db_session_with_containers: Session):
"""Test ONLY_ME permission denies access to non-creators."""
@@ -447,7 +447,7 @@ class TestDatasetServiceCheckDatasetPermission:
)
with pytest.raises(NoPermissionError):
DatasetService.check_dataset_permission(dataset, other)
DatasetService.check_dataset_permission(dataset, other, db_session_with_containers)
def test_check_dataset_permission_all_team_allows_access(self, db_session_with_containers: Session):
"""Test ALL_TEAM permission allows any team member to access the dataset."""
@@ -460,7 +460,7 @@ class TestDatasetServiceCheckDatasetPermission:
tenant.id, creator.id, permission=DatasetPermissionEnum.ALL_TEAM
)
DatasetService.check_dataset_permission(dataset, member)
DatasetService.check_dataset_permission(dataset, member, db_session_with_containers)
def test_check_dataset_permission_partial_members_with_permission_success(
self, db_session_with_containers: Session
@@ -483,7 +483,7 @@ class TestDatasetServiceCheckDatasetPermission:
DatasetPermissionTestDataFactory.create_dataset_permission(dataset.id, user.id, tenant.id)
# Act (should not raise)
DatasetService.check_dataset_permission(dataset, user)
DatasetService.check_dataset_permission(dataset, user, db_session_with_containers)
# Assert
permissions = DatasetPermissionService.get_dataset_partial_member_list(dataset.id)
@@ -510,7 +510,7 @@ class TestDatasetServiceCheckDatasetPermission:
# Act & Assert
with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset"):
DatasetService.check_dataset_permission(dataset, user)
DatasetService.check_dataset_permission(dataset, user, db_session_with_containers)
def test_check_dataset_permission_partial_team_creator_can_access(self, db_session_with_containers: Session):
"""Test PARTIAL_TEAM permission allows creator to access without explicit permission."""
@@ -520,7 +520,7 @@ class TestDatasetServiceCheckDatasetPermission:
tenant.id, creator.id, permission=DatasetPermissionEnum.PARTIAL_TEAM
)
DatasetService.check_dataset_permission(dataset, creator)
DatasetService.check_dataset_permission(dataset, creator, db_session_with_containers)
class TestDatasetServiceCheckDatasetOperatorPermission:
@@ -237,7 +237,7 @@ class TestDatasetServicePermissionsAndLifecycle:
)
with pytest.raises(NoPermissionError, match="do not have permission"):
DatasetService.check_dataset_permission(dataset, outsider)
DatasetService.check_dataset_permission(dataset, outsider, db_session_with_containers)
def test_check_dataset_permission_rejects_only_me_dataset_for_non_creator(
self, db_session_with_containers: Session
@@ -252,7 +252,7 @@ class TestDatasetServicePermissionsAndLifecycle:
)
with pytest.raises(NoPermissionError, match="do not have permission"):
DatasetService.check_dataset_permission(dataset, member)
DatasetService.check_dataset_permission(dataset, member, db_session_with_containers)
def test_check_dataset_permission_rejects_partial_team_user_without_binding(
self, db_session_with_containers: Session
@@ -267,7 +267,7 @@ class TestDatasetServicePermissionsAndLifecycle:
)
with pytest.raises(NoPermissionError, match="do not have permission"):
DatasetService.check_dataset_permission(dataset, member)
DatasetService.check_dataset_permission(dataset, member, db_session_with_containers)
def test_check_dataset_permission_allows_partial_team_creator(self, db_session_with_containers: Session):
creator, tenant = DatasetPermissionIntegrationFactory.create_account_with_tenant(
@@ -281,7 +281,7 @@ class TestDatasetServicePermissionsAndLifecycle:
permission=DatasetPermissionEnum.PARTIAL_TEAM,
)
DatasetService.check_dataset_permission(dataset, creator)
DatasetService.check_dataset_permission(dataset, creator, db_session_with_containers)
def test_check_dataset_permission_allows_partial_team_member_with_binding(
self, db_session_with_containers: Session
@@ -301,7 +301,7 @@ class TestDatasetServicePermissionsAndLifecycle:
account_id=member.id,
)
DatasetService.check_dataset_permission(dataset, member)
DatasetService.check_dataset_permission(dataset, member, db_session_with_containers)
def test_check_dataset_operator_permission_rejects_only_me_for_non_creator(
self, db_session_with_containers: Session
@@ -109,7 +109,9 @@ def test_request_builder_separates_agent_soul_and_workflow_job_prompt():
dumped = request.model_dump(mode="json")
assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are a careful reviewer."
assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Review the previous node output."
workflow_job_config = dumped["composition"]["layers"][1]["config"]
assert workflow_job_config["user"] == "Review the previous node output."
assert not workflow_job_config.get("prefix")
assert dumped["composition"]["layers"][2]["config"]["user"] == "Summarize the report."
@@ -162,8 +164,15 @@ def test_request_builder_adds_knowledge_layer_when_configured():
run_input = _run_input()
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
{
"dataset_ids": ["dataset-1"],
"retrieval": {"mode": "multiple", "top_k": 4},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
],
}
)
@@ -174,7 +183,7 @@ def test_request_builder_adds_knowledge_layer_when_configured():
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config)
assert knowledge_config.dataset_ids == ["dataset-1"]
assert knowledge_config.sets[0].dataset_ids == ["dataset-1"]
def test_request_builder_can_delete_on_exit_for_cleanup_paths():
@@ -332,7 +341,7 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell():
assert shell_config.env[0].name == "PROJECT_NAME"
def test_workflow_request_builder_binds_shell_to_drive_when_configured():
def test_workflow_request_builder_binds_drive_to_shell_when_configured():
run_input = _run_input()
run_input.include_shell = True
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
@@ -341,11 +350,11 @@ def test_workflow_request_builder_binds_shell_to_drive_when_configured():
layers = {layer.name: layer for layer in request.composition.layers}
layer_names = [layer.name for layer in request.composition.layers]
assert layers[DIFY_SHELL_LAYER_ID].deps == {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"drive": DIFY_DRIVE_LAYER_ID,
}
assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID)
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
assert shell_config.agent_stub_drive_ref == "agent-agent-1"
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
def test_agent_app_request_builder_omits_shell_layer_by_default():
@@ -367,7 +376,7 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
assert shell_config.env[0].name == "APP_ENV"
def test_agent_app_request_builder_binds_shell_to_drive_when_configured():
def test_agent_app_request_builder_binds_drive_to_shell_when_configured():
run_input = _agent_app_input(include_shell=True)
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
@@ -375,19 +384,26 @@ def test_agent_app_request_builder_binds_shell_to_drive_when_configured():
layers = {layer.name: layer for layer in request.composition.layers}
layer_names = [layer.name for layer in request.composition.layers]
assert layers[DIFY_SHELL_LAYER_ID].deps == {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"drive": DIFY_DRIVE_LAYER_ID,
}
assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID)
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
assert shell_config.agent_stub_drive_ref == "agent-agent-1"
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
run_input = _agent_app_input()
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
{
"dataset_ids": ["dataset-1", "dataset-2"],
"retrieval": {"mode": "multiple", "top_k": 2},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 2},
}
],
}
)
@@ -398,7 +414,7 @@ def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config)
assert knowledge_config.dataset_ids == ["dataset-1", "dataset-2"]
assert knowledge_config.sets[0].dataset_ids == ["dataset-1", "dataset-2"]
# ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ─────
@@ -149,3 +149,55 @@ def test_generate_specs_is_idempotent(tmp_path):
assert [path.name for path in first_paths] == [path.name for path in second_paths]
for first_path, second_path in zip(first_paths, second_paths):
assert first_path.read_text(encoding="utf-8") == second_path.read_text(encoding="utf-8")
def test_generate_specs_include_agent_v2_knowledge_set_schema_and_query_enums(tmp_path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
console_path = next(path for path in written_paths if path.name == "console-openapi.json")
payload = json.loads(console_path.read_text(encoding="utf-8"))
schemas = payload["components"]["schemas"]
assert "AgentKnowledgeSetConfig" in schemas
assert schemas["AgentSoulKnowledgeConfig"]["properties"]["sets"]["items"]["$ref"] == (
"#/components/schemas/AgentKnowledgeSetConfig"
)
assert schemas["AgentKnowledgeQueryMode"]["enum"] == ["generated_query", "user_query"]
def test_checked_in_agent_v2_knowledge_openapi_and_generated_contracts_are_in_sync():
api_dir = Path(__file__).resolve().parents[3]
repo_root = api_dir.parent
markdown = (api_dir / "openapi" / "markdown" / "console-openapi.md").read_text(encoding="utf-8")
agent_types = (
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "types.gen.ts"
).read_text(encoding="utf-8")
apps_types = (
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "types.gen.ts"
).read_text(encoding="utf-8")
agent_zod = (
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "zod.gen.ts"
).read_text(encoding="utf-8")
apps_zod = (
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "zod.gen.ts"
).read_text(encoding="utf-8")
assert "#### AgentKnowledgeSetConfig" in markdown
assert "#### AgentSoulKnowledgeConfig" in markdown
assert "#### AgentKnowledgeQueryMode" in markdown
for content in (agent_types, apps_types):
assert "export type AgentKnowledgeSetConfig = {" in content
assert "export type AgentSoulKnowledgeConfig = {" in content
assert "AgentKnowledgeQueryMode" in content
assert "generated_query" in content
assert "user_query" in content
for content in (agent_zod, apps_zod):
assert "export const zAgentKnowledgeSetConfig = z.object({" in content
assert "export const zAgentSoulKnowledgeConfig = z.object({" in content
assert "zAgentKnowledgeQueryMode = z.enum([" in content
assert "generated_query" in content
assert "user_query" in content
@@ -28,11 +28,15 @@ from controllers.console.agent.roster import (
AgentAppApi,
AgentAppCopyApi,
AgentAppListApi,
AgentBuildDraftApi,
AgentBuildDraftApplyApi,
AgentBuildDraftCheckoutApi,
AgentDebugConversationRefreshApi,
AgentInviteOptionsApi,
AgentLogMessagesApi,
AgentLogsApi,
AgentLogSourcesApi,
AgentPublishApi,
AgentRosterVersionDetailApi,
AgentRosterVersionRestoreApi,
AgentRosterVersionsApi,
@@ -95,7 +99,7 @@ def _agent_app_composer_response() -> dict:
},
"active_config_snapshot": _version_response(),
"agent_soul": {},
"save_options": ["save_to_current_version", "save_as_new_version"],
"save_options": ["save_to_current_version"],
}
@@ -151,6 +155,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/agent/<uuid:agent_id>/composer/candidates",
"/agent/<uuid:agent_id>/features",
"/agent/<uuid:agent_id>/copy",
"/agent/<uuid:agent_id>/publish",
"/agent/<uuid:agent_id>/build-draft/checkout",
"/agent/<uuid:agent_id>/build-draft",
"/agent/<uuid:agent_id>/build-draft/apply",
"/agent/<uuid:agent_id>/referencing-workflows",
"/agent/<uuid:agent_id>/drive/files",
"/agent/<uuid:agent_id>/sandbox/files",
@@ -233,6 +241,8 @@ def test_agent_app_list_and_create_use_agent_route(
lambda _self, **kwargs: {
"app-list": SimpleNamespace(
id="agent-list",
app_id="app-list",
backing_app_id=None,
role="List role",
debug_conversation_id="debug-conversation-list",
active_config_snapshot_id=None,
@@ -244,6 +254,8 @@ def test_agent_app_list_and_create_use_agent_route(
"get_app_backing_agent",
lambda _self, **kwargs: SimpleNamespace(
id="agent-created",
app_id="app-created",
backing_app_id=None,
role="Created role",
debug_conversation_id="debug-conversation-created",
active_config_snapshot_id=None,
@@ -368,6 +380,14 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id)
agent = SimpleNamespace(
id=agent_id,
app_id="app-1",
backing_app_id=None,
role="Resolved role",
debug_conversation_id="debug-conversation-detail",
active_config_snapshot_id=None,
)
captured: dict[str, object] = {}
monkeypatch.setattr(
@@ -375,15 +395,12 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
"get_agent_app_model",
lambda _self, **kwargs: app_model,
)
monkeypatch.setattr(roster_controller, "resolve_agent_runtime_app_model", lambda **kwargs: app_model)
monkeypatch.setattr(roster_controller.db.session, "scalar", lambda _stmt: agent)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_app_backing_agent",
lambda _self, **kwargs: SimpleNamespace(
id=agent_id,
role="Resolved role",
debug_conversation_id="debug-conversation-detail",
active_config_snapshot_id=None,
),
lambda _self, **kwargs: agent,
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
@@ -520,6 +537,129 @@ def test_agent_debug_conversation_refresh_uses_current_user(
}
def test_agent_publish_and_build_draft_routes_call_composer_service(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
current_user = SimpleNamespace(id=account_id)
captured: dict[str, object] = {}
def publish_agent_app_draft(**kwargs: object) -> dict[str, object]:
captured["publish"] = kwargs
return {"result": "success", "active_config_snapshot_id": "version-1"}
def checkout_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
captured["checkout"] = kwargs
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
def load_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
captured["load"] = kwargs
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
def save_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
captured["save"] = kwargs
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
def apply_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
captured["apply"] = kwargs
return {"result": "success", "draft": {"id": "draft-1"}}
def discard_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
captured["discard"] = kwargs
return {"result": "success"}
monkeypatch.setattr(
roster_controller.AgentComposerService,
"publish_agent_app_draft",
publish_agent_app_draft,
)
monkeypatch.setattr(
roster_controller.AgentComposerService,
"checkout_agent_app_build_draft",
checkout_agent_app_build_draft,
)
monkeypatch.setattr(
roster_controller.AgentComposerService,
"load_agent_app_build_draft",
load_agent_app_build_draft,
)
monkeypatch.setattr(
roster_controller.AgentComposerService,
"save_agent_app_build_draft",
save_agent_app_build_draft,
)
monkeypatch.setattr(
roster_controller.AgentComposerService,
"apply_agent_app_build_draft",
apply_agent_app_build_draft,
)
monkeypatch.setattr(
roster_controller.AgentComposerService,
"discard_agent_app_build_draft",
discard_agent_app_build_draft,
)
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/publish",
json={"version_note": "publish v1"},
):
published = unwrap(AgentPublishApi.post)(AgentPublishApi(), "tenant-1", current_user, agent_id)
assert published["active_config_snapshot_id"] == "version-1"
assert captured["publish"] == {
"tenant_id": "tenant-1",
"agent_id": agent_id,
"account_id": account_id,
"version_note": "publish v1",
}
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/checkout",
json={"force": True},
):
checked_out = unwrap(AgentBuildDraftCheckoutApi.post)(
AgentBuildDraftCheckoutApi(), "tenant-1", current_user, agent_id
)
assert checked_out["draft"]["id"] == "build-draft-1"
assert captured["checkout"] == {
"tenant_id": "tenant-1",
"agent_id": agent_id,
"account_id": account_id,
"force": True,
}
with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft"):
loaded = unwrap(AgentBuildDraftApi.get)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
assert loaded["draft"]["id"] == "build-draft-1"
assert captured["load"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft",
json={"variant": "agent_app", "save_strategy": "save_to_current_version", "agent_soul": {}},
):
saved = unwrap(AgentBuildDraftApi.put)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
assert saved["draft"]["id"] == "build-draft-1"
assert captured["save"]["tenant_id"] == "tenant-1"
assert captured["save"]["agent_id"] == agent_id
assert captured["save"]["account_id"] == account_id
assert captured["save"]["payload"].variant == ComposerVariant.AGENT_APP
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/apply",
method="POST",
):
applied = unwrap(AgentBuildDraftApplyApi.post)(AgentBuildDraftApplyApi(), "tenant-1", current_user, agent_id)
assert applied == {"result": "success", "draft": {"id": "draft-1"}}
assert captured["apply"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft",
method="DELETE",
):
discarded = unwrap(AgentBuildDraftApi.delete)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
assert discarded == {"result": "success"}
assert captured["discard"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -751,7 +891,12 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc
restored = unwrap(AgentRosterVersionRestoreApi.post)(
AgentRosterVersionRestoreApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id, version_id
)
assert restored == {"result": "success", "active_config_snapshot_id": version_id}
assert restored == {
"result": "success",
"active_config_snapshot_id": version_id,
"draft_config_id": None,
"restored_version_id": None,
}
assert captured_restore == {
"tenant_id": "tenant-1",
"agent_id": agent_id,
@@ -887,7 +1032,7 @@ def test_agent_observability_routes_resolve_app_from_agent_id(
},
}
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
monkeypatch.setattr(roster_controller, "resolve_agent_runtime_app_model", lambda **kwargs: app_model)
monkeypatch.setattr(roster_controller, "_agent_observability_service", lambda: FakeObservabilityService())
account = SimpleNamespace(id=account_id, timezone="UTC")
@@ -963,10 +1108,11 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value,
"binding": {"binding_type": "roster_agent", "current_snapshot_id": "version-1"},
}
captured_load: dict[str, object] = {}
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_workflow_composer",
lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]),
lambda **kwargs: captured_load.update(kwargs) or _workflow_composer_response(node_id=kwargs["node_id"]),
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
@@ -993,8 +1139,12 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
},
)
workflow_state = unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1")
with app.test_request_context("?snapshot_id=preview-version"):
workflow_state = unwrap(WorkflowAgentComposerApi.get)(
WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1"
)
assert workflow_state["node_id"] == "node-1"
assert captured_load["snapshot_id"] == "preview-version"
with app.test_request_context(json=payload):
saved_state = unwrap(WorkflowAgentComposerApi.put)(
WorkflowAgentComposerApi(), "tenant-1", account_id, app_model, "node-1"
@@ -1092,13 +1242,11 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
"agent_soul": {"prompt": {"system_prompt": "x"}},
}
monkeypatch.setattr(composer_controller, "resolve_agent_app_model", lambda **kwargs: SimpleNamespace(id="app-1"))
def load_agent_app_composer(**kwargs: object) -> dict:
def load_agent_composer(**kwargs: object) -> dict:
captured["load"] = kwargs
return _agent_app_composer_response()
def save_agent_app_composer(**kwargs: object) -> dict:
def save_agent_composer(**kwargs: object) -> dict:
captured["save"] = kwargs
return _agent_app_composer_response()
@@ -1112,13 +1260,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_agent_app_composer",
load_agent_app_composer,
"load_agent_composer",
load_agent_composer,
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_agent_app_composer",
save_agent_app_composer,
"save_agent_composer",
save_agent_composer,
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
monkeypatch.setattr(
@@ -1133,13 +1281,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
)
assert unwrap(AgentComposerApi.get)(AgentComposerApi(), "tenant-1", agent_id)["variant"] == "agent_app"
assert cast(dict[str, object], captured["load"])["app_id"] == "app-1"
assert cast(dict[str, object], captured["load"])["agent_id"] == agent_id
with app.test_request_context(json=payload):
assert (
unwrap(AgentComposerApi.put)(AgentComposerApi(), "tenant-1", account_id, agent_id)["variant"] == "agent_app"
)
assert cast(dict[str, object], captured["save"])["app_id"] == "app-1"
assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id
assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), "tenant-1", agent_id) == {
"result": "success",
"errors": [],
@@ -1150,7 +1298,7 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
candidates = unwrap(AgentComposerCandidatesApi.get)(AgentComposerCandidatesApi(), "tenant-1", account_id, agent_id)
assert candidates["variant"] == "agent_app"
assert cast(dict[str, object], captured["candidates"])["app_id"] == "app-1"
assert cast(dict[str, object], captured["candidates"])["agent_id"] == agent_id
def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
@@ -1172,7 +1320,7 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
captured["stop"] = kwargs
return {"result": "success"}, 200
monkeypatch.setattr(completion_controller, "resolve_agent_app_model", resolve_agent_app_model)
monkeypatch.setattr(completion_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model)
monkeypatch.setattr(completion_controller, "_create_chat_message", create_chat_message)
monkeypatch.setattr(completion_controller, "_stop_chat_message", stop_chat_message)
@@ -1375,7 +1523,7 @@ def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeyp
captured["detail"] = kwargs
return {"id": message_id}
monkeypatch.setattr(message_controller, "resolve_agent_app_model", resolve_agent_app_model)
monkeypatch.setattr(message_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model)
monkeypatch.setattr(message_controller, "_list_chat_messages", list_chat_messages)
monkeypatch.setattr(message_controller, "_update_message_feedback", update_message_feedback)
monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions)
@@ -119,7 +119,7 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
service = _AgentAppService()
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model())
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", lambda *, tenant_id, agent_id: _app_model())
monkeypatch.setattr(
module,
"query_params_from_request",
@@ -151,7 +151,7 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytes
raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404)
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model())
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", lambda *, tenant_id, agent_id: _app_model())
monkeypatch.setattr(
module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".")
)
@@ -64,7 +64,7 @@ def test_list_by_agent_filters_value_pointers_out_of_console_payload():
raw = _raw(AgentDriveListByAgentApi.get)
with app.test_request_context("/?prefix=pdf-toolkit/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.manifest.return_value = [
@@ -105,7 +105,7 @@ def test_skill_list_by_agent_calls_service():
raw = _raw(AgentDriveSkillListByAgentApi.get)
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.list_skills.return_value = [
@@ -177,7 +177,7 @@ def test_skill_inspect_by_agent_returns_strict_json_response():
}
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP),
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.inspect_skill.return_value = payload
@@ -256,7 +256,7 @@ def test_preview_by_agent_passes_through_and_maps_errors():
raw = _raw(AgentDrivePreviewByAgentApi.get)
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.preview.return_value = {
@@ -272,7 +272,7 @@ def test_preview_by_agent_passes_through_and_maps_errors():
with app.test_request_context("/?key=ghost/SKILL.md"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP),
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.preview.side_effect = AgentDriveError(
@@ -296,7 +296,7 @@ def test_download_by_agent_returns_signed_url_json():
raw = _raw(AgentDriveDownloadByAgentApi.get)
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.download_url.return_value = "https://signed.example/zip"
@@ -67,7 +67,7 @@ def test_upload_by_agent_resolves_app_and_standardizes_into_drive():
with _file_ctx(files={"file": b"zip-bytes"}):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.SkillStandardizeService") as svc,
):
svc.return_value.standardize.return_value = {"skill": {"path": "skill-a"}, "manifest": {}}
@@ -174,7 +174,7 @@ def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id():
upload = SimpleNamespace(id="uf-1", name="sample.pdf")
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.console_ns") as ns,
patch(f"{_MOD}.db") as db_mock,
patch(f"{_MOD}.AgentDriveService") as drive,
@@ -252,7 +252,7 @@ def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id():
raw = _raw(AgentDriveFilesByAgentApi.delete)
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
@@ -316,7 +316,7 @@ def test_skill_delete_by_agent_uses_agent_route():
raw = _raw(AgentSkillByAgentApi.delete)
with _json_ctx(method="DELETE", query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}]
@@ -360,7 +360,7 @@ def test_infer_tools_by_agent_uses_agent_route():
raw = _raw(AgentSkillInferToolsByAgentApi.post)
with _json_ctx(query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.SkillToolInferenceService") as svc,
):
svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None}
@@ -379,6 +379,7 @@ def test_app_detail_with_site_includes_nested_serialization(app_models):
title="Public Site",
icon_type="image",
icon="site-icon",
input_placeholder="Ask anything",
created_at=timestamp,
updated_at=timestamp,
)
@@ -421,6 +422,7 @@ def test_app_detail_with_site_includes_nested_serialization(app_models):
assert serialized["model_config"]["retriever_resource"] == {"enabled": True}
assert serialized["deleted_tools"][0]["tool_name"] == "search"
assert serialized["site"]["icon_url"] == "signed:site-icon"
assert serialized["site"]["input_placeholder"] == "Ask anything"
assert serialized["site"]["created_at"] == int(timestamp.timestamp())
assert serialized["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
assert serialized["bound_agent_id"] == "agent-1"
@@ -65,7 +65,7 @@ class TestCheckVersionUpdate:
assert result.features.can_replace_logo is True
assert result.features.model_load_balancing_enabled is False
def test_http_error_fallback(self):
def test_http_error_fallback(self, caplog: pytest.LogCaptureFixture):
query = version_module.VersionQuery(current_version="1.0.0")
with (
@@ -79,15 +79,12 @@ class TestCheckVersionUpdate:
"get",
side_effect=Exception("boom"),
),
patch.object(
version_module.logger,
"warning",
) as log_warning,
caplog.at_level(logging.WARNING, logger="controllers.console.version"),
):
result = version_module.check_version_update(query)
assert result.version == "1.0.0"
log_warning.assert_called_once()
assert "Check update version error" in caplog.text
def test_new_version_available(self):
query = version_module.VersionQuery(current_version="1.0.0")
@@ -112,6 +112,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
chat_color_theme_inverted=False,
copyright=None,
privacy_policy=None,
input_placeholder="Ask me anything",
custom_disclaimer=None,
prompt_public=False,
show_workflow_steps=True,
@@ -132,7 +133,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
monkeypatch.setattr(
site_module.FeatureService,
"get_features",
lambda tenant_id, **_kwargs: SimpleNamespace(can_replace_logo=True),
lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True),
)
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
@@ -167,6 +168,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
"description": "desc",
"copyright": None,
"privacy_policy": None,
"input_placeholder": "Ask me anything",
"custom_disclaimer": None,
"default_language": "en",
"prompt_public": False,
@@ -186,6 +188,72 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
def test_serialize_app_site_payload_masks_paid_webapp_fields_when_feature_disabled(monkeypatch: pytest.MonkeyPatch):
"""Runtime site payload hides paid-only fields for plans that cannot use them."""
tenant = SimpleNamespace(id="tenant-1", plan="sandbox", custom_config_dict={})
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
site_model = SimpleNamespace(
title="My Site",
icon_type="emoji",
icon="robot",
icon_background="#fff",
description="desc",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
copyright="Dify",
privacy_policy=None,
input_placeholder="Ask me anything",
custom_disclaimer=None,
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
)
features = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=False)
features.billing.enabled = True
monkeypatch.setattr(site_module.FeatureService, "get_features", lambda tenant_id, **_kwargs: features)
payload = site_module.serialize_app_site_payload(app_model, site_model, end_user_id=None)
assert payload["site"]["copyright"] is None
assert payload["site"]["input_placeholder"] is None
def test_serialize_app_site_payload_keeps_paid_webapp_fields_when_billing_disabled(monkeypatch: pytest.MonkeyPatch):
"""Self-hosted community runtimes keep site fields because billing is not enforcing the paid gate."""
tenant = SimpleNamespace(id="tenant-1", plan="basic", custom_config_dict={})
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
site_model = SimpleNamespace(
title="My Site",
icon_type="emoji",
icon="robot",
icon_background="#fff",
description="desc",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
copyright="Dify",
privacy_policy=None,
input_placeholder="Ask me anything",
custom_disclaimer=None,
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
)
monkeypatch.setattr(
site_module.FeatureService,
"get_features",
lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=False, webapp_copyright_enabled=False),
)
payload = site_module.serialize_app_site_payload(app_model, site_model, end_user_id=None)
assert payload["site"]["copyright"] == "Dify"
assert payload["site"]["input_placeholder"] == "Ask me anything"
def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, app: Flask):
"""GET returns variable-backed select options resolved from runtime state."""
@@ -256,6 +324,7 @@ def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, a
chat_color_theme_inverted=False,
copyright=None,
privacy_policy=None,
input_placeholder="Ask me anything",
custom_disclaimer=None,
prompt_public=False,
show_workflow_steps=True,
@@ -380,6 +449,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
chat_color_theme_inverted=False,
copyright=None,
privacy_policy=None,
input_placeholder="Ask me anything",
custom_disclaimer=None,
prompt_public=False,
show_workflow_steps=True,
@@ -397,7 +467,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
monkeypatch.setattr(
site_module.FeatureService,
"get_features",
lambda tenant_id, **_kwargs: SimpleNamespace(can_replace_logo=True),
lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True),
)
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
@@ -432,6 +502,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
"description": "desc",
"copyright": None,
"privacy_policy": None,
"input_placeholder": "Ask me anything",
"custom_disclaimer": None,
"default_language": "en",
"prompt_public": False,
@@ -69,7 +69,7 @@ class TestGenerateSuccess:
def test_runtime_session_snapshot_id_is_stable_for_debugger_only(self):
assert (
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1")
is None
== "snap-1"
)
assert (
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.WEB_APP, snapshot_id="snap-1")
@@ -111,7 +111,12 @@ class TestGenerateSuccess:
assert result == {"result": "ok"}
thread_obj.start.assert_called_once()
generator._resolve_agent.assert_called_once_with(app_model)
generator._resolve_agent.assert_called_once_with(
app_model,
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=user,
)
def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
@@ -46,6 +46,13 @@ class _FakeCredentialsProvider:
return {"openai_api_key": "sk-test"}
@pytest.fixture(autouse=True)
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
class _NoToolsBuilder:
def build(self, **kwargs):
del kwargs
@@ -14,6 +14,7 @@ import pytest
from core.app.apps.agent_app import app_generator as gen_mod
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError
from core.app.entities.app_invoke_entities import InvokeFrom
_SOUL_DICT = {
"model": {
@@ -84,14 +85,24 @@ class TestResolveAgent:
_patch_session(monkeypatch, [bound_agent, inner_agent, snapshot])
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
agent, snap, soul = AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type]
agent, snap, soul = AgentAppGenerator()._resolve_agent(
app_model,
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=SimpleNamespace(id="user-1"),
) # type: ignore[arg-type]
assert agent is inner_agent
assert snap is snapshot
assert agent is bound_agent
assert snap == snapshot.id
assert soul.model is not None
def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch):
_patch_session(monkeypatch, [None])
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
with pytest.raises(AgentAppGeneratorError, match="has no bound Agent"):
AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type]
AgentAppGenerator()._resolve_agent(
app_model,
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=SimpleNamespace(id="user-1"),
) # type: ignore[arg-type]
@@ -85,6 +85,49 @@ class _NoToolsBuilder:
del kwargs
def _mock_empty_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 123,
"mime_type": "text/markdown",
"hash": "hash-1",
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False},
{"key": "files/sample.pdf", "is_skill": False},
],
)
@pytest.fixture(autouse=True)
def _mock_default_agent_app_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
_mock_empty_drive_catalog(monkeypatch)
def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuildContext:
dify_context = SimpleNamespace(
tenant_id="tenant-1",
@@ -128,7 +171,15 @@ class TestAgentAppRuntimeRequestBuilder:
req = result.request
assert req.purpose == "agent_app"
names = [layer.name for layer in req.composition.layers]
assert names == ["agent_soul_prompt", "agent_app_user_prompt", "execution_context", "history", "llm"]
assert names == [
"agent_soul_prompt",
"agent_app_user_prompt",
"execution_context",
"shell",
"drive",
"history",
"llm",
]
# plugin_id / provider normalized for plugin-daemon transport.
llm = next(layer for layer in req.composition.layers if layer.name == "llm")
assert llm.config.plugin_id == "langgenius/openai"
@@ -169,12 +220,19 @@ class TestAgentAppRuntimeRequestBuilder:
"model": "gpt-4o-mini",
},
"knowledge": {
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
"query_config": {
"top_k": 3,
"score_threshold": 0.5,
"score_threshold_enabled": False,
},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {
"mode": "multiple",
"top_k": 3,
"score_threshold": None,
},
}
],
},
}
)
@@ -189,10 +247,12 @@ class TestAgentAppRuntimeRequestBuilder:
assert knowledge.type == "dify.knowledge_base"
assert knowledge.deps == {"execution_context": "execution_context"}
dumped_config = knowledge.config.model_dump(mode="json", by_alias=True)
assert dumped_config["dataset_ids"] == ["dataset-1", "dataset-2"]
assert dumped_config["retrieval"]["mode"] == "multiple"
assert dumped_config["retrieval"]["top_k"] == 3
assert dumped_config["retrieval"]["score_threshold"] == 0.0
knowledge_set = dumped_config["sets"][0]
assert [dataset["id"] for dataset in knowledge_set["datasets"]] == ["dataset-1", "dataset-2"]
assert knowledge_set["query"] == {"mode": "generated_query", "value": None}
assert knowledge_set["retrieval"]["mode"] == "multiple"
assert knowledge_set["retrieval"]["top_k"] == 3
assert knowledge_set["retrieval"]["score_threshold"] == 0.0
def test_build_raises_when_model_missing(self):
builder = AgentAppRuntimeRequestBuilder(
@@ -242,9 +302,26 @@ class TestAgentAppRuntimeRequestBuilder:
def _soul_with_model_and_skill() -> AgentSoulConfig:
soul = _soul_with_model()
soul.prompt.system_prompt = "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"
return soul
return AgentSoulConfig.model_validate(
{
"model": {
"plugin_id": "langgenius/openai",
"model_provider": "langgenius/openai/openai",
"model": "gpt-4o-mini",
},
"prompt": {"system_prompt": "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"},
"files": {
"skills": [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
}
]
},
}
)
class TestAgentAppDriveLayer:
@@ -252,28 +329,7 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 1,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True}
],
)
_mock_drive_catalog(monkeypatch)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@@ -283,27 +339,20 @@ class TestAgentAppDriveLayer:
drive = next(layer for layer in result.request.composition.layers if layer.name == "drive")
assert drive.type == "dify.drive"
assert drive.deps == {"execution_context": "execution_context"}
assert drive.deps == {"shell": DIFY_SHELL_LAYER_ID}
assert drive.config.drive_ref == "agent-agent-1"
assert [skill.skill_md_key for skill in drive.config.skills] == ["tender-analyzer/SKILL.md"]
assert drive.config.mentioned_skill_keys == ["tender-analyzer/SKILL.md"]
# injected right after execution_context, mirroring the workflow surface
# shell enters first; drive uses that shell to materialize mentioned targets.
names = [layer.name for layer in result.request.composition.layers]
assert names.index("drive") == names.index("execution_context") + 1
assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 1
assert names.index("drive") == names.index(DIFY_SHELL_LAYER_ID) + 1
def test_drive_layer_injected_with_empty_catalog_and_shell_depends_on_it(self, monkeypatch: pytest.MonkeyPatch):
def test_drive_layer_injected_with_empty_catalog_and_drive_depends_on_shell(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@@ -314,12 +363,14 @@ class TestAgentAppDriveLayer:
layers = {layer.name: layer for layer in result.request.composition.layers}
assert layers["drive"].config.drive_ref == "agent-agent-1"
assert layers["drive"].config.skills == []
assert layers[DIFY_SHELL_LAYER_ID].deps == {
"execution_context": "execution_context",
"drive": "drive",
}
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": "execution_context"}
assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref == "agent-agent-1"
assert layers["drive"].deps == {"shell": DIFY_SHELL_LAYER_ID}
def test_no_drive_layer_when_flag_disabled(self):
def test_no_drive_layer_when_flag_disabled(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@@ -334,29 +385,7 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 1,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "files/sample.pdf", "is_skill": False},
],
)
_mock_drive_catalog(monkeypatch)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
@@ -379,14 +408,7 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
_mock_drive_catalog(monkeypatch)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
@@ -407,29 +429,7 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 1,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "files/sample.pdf", "is_skill": False},
],
)
_mock_drive_catalog(monkeypatch)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]"
@@ -452,14 +452,6 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
@@ -394,13 +394,15 @@ def test_switch_preferred_provider_type_returns_early_when_no_change_or_unsuppor
configuration = _build_provider_configuration()
with patch("core.entities.provider_configuration.Session") as mock_session_cls:
configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
assert changed is False
mock_session_cls.assert_not_called()
configuration.preferred_provider_type = ProviderType.CUSTOM
configuration.system_configuration.enabled = False
with patch("core.entities.provider_configuration.Session") as mock_session_cls:
configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
assert changed is False
mock_session_cls.assert_not_called()
@@ -411,10 +413,13 @@ def test_switch_preferred_provider_type_updates_existing_record_with_session() -
existing_record = SimpleNamespace(preferred_provider_type="custom")
session.execute.return_value.scalars.return_value.first.return_value = existing_record
configuration.switch_preferred_provider_type(ProviderType.SYSTEM, session=session)
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM, session=session)
assert changed is True
assert existing_record.preferred_provider_type == ProviderType.SYSTEM
session.commit.assert_called_once()
mock_invalidate.assert_not_called()
def test_switch_preferred_provider_type_creates_record_when_missing() -> None:
@@ -423,10 +428,13 @@ def test_switch_preferred_provider_type_creates_record_when_missing() -> None:
session = Mock()
session.execute.return_value.scalars.return_value.first.return_value = None
configuration.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
changed = configuration.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
assert changed is True
assert session.add.call_count == 1
session.commit.assert_called_once()
mock_invalidate.assert_not_called()
def test_get_model_type_instance_and_schema_delegate_to_factory() -> None:
@@ -1022,13 +1030,14 @@ def test_update_load_balancing_configs_updates_all_matching_configs() -> None:
credential_record = SimpleNamespace(encrypted_config='{"api_key":"enc"}', credential_name="API KEY 3")
with patch("core.entities.provider_configuration.ProviderCredentialsCache") as mock_cache:
configuration._update_load_balancing_configs_with_credential(
changed = configuration._update_load_balancing_configs_with_credential(
credential_id="cred-1",
credential_record=credential_record,
credential_source=CredentialSourceType.PROVIDER,
session=session,
)
assert changed is True
assert lb_config.encrypted_config == '{"api_key":"enc"}'
assert lb_config.name == "API KEY 3"
mock_cache.return_value.delete.assert_called_once()
@@ -1040,13 +1049,14 @@ def test_update_load_balancing_configs_returns_when_no_matching_configs() -> Non
session = Mock()
session.execute.return_value.scalars.return_value.all.return_value = []
configuration._update_load_balancing_configs_with_credential(
changed = configuration._update_load_balancing_configs_with_credential(
credential_id="cred-1",
credential_record=SimpleNamespace(encrypted_config="{}", credential_name="Main"),
credential_source=CredentialSourceType.PROVIDER,
session=session,
)
assert changed is False
session.commit.assert_not_called()
@@ -1478,12 +1488,15 @@ def test_model_load_balancing_enable_disable_and_switch_preferred_provider_type_
switch_session = Mock()
with _patched_session(switch_session):
switch_session.execute.return_value.scalars.return_value.first.return_value = None
configuration.switch_preferred_provider_type(ProviderType.CUSTOM)
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
changed = configuration.switch_preferred_provider_type(ProviderType.CUSTOM)
assert changed is True
assert any(
call.args and call.args[0].__class__.__name__ == "TenantPreferredModelProvider"
for call in switch_session.add.call_args_list
)
switch_session.commit.assert_called()
mock_invalidate.assert_called_once_with(preferred_model_providers=True)
def test_system_and_custom_provider_model_helpers_cover_remaining_skip_paths() -> None:
@@ -5,10 +5,17 @@ import pytest
from pytest_mock import MockerFixture
from core.entities.provider_entities import ModelSettings
from core.provider_manager import ProviderManager
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.model_entities import ModelType
from models.provider import LoadBalancingModelConfig, ProviderModelSetting, TenantDefaultModel
from models.provider import (
LoadBalancingModelConfig,
Provider,
ProviderCredential,
ProviderModelSetting,
ProviderType,
TenantDefaultModel,
)
from models.provider_ids import ModelProviderID
@@ -23,6 +30,43 @@ def _build_session_context(session: Mock) -> MagicMock:
return session_cm
class _FakeRedis:
def __init__(self) -> None:
self.store: dict[str, str] = {}
self.expirations: dict[str, int] = {}
def get(self, key: str):
return self.store.get(key)
def set(self, key: str, value: str, *, ex: int | None = None) -> None:
self.store[key] = value
if ex is not None:
self.expirations[key] = ex
def setex(self, key: str, time: int, value: str) -> None:
self.store[key] = value
self.expirations[key] = time
def incr(self, key: str) -> int:
value = int(self.store.get(key, "0")) + 1
self.store[key] = str(value)
return value
def expire(self, key: str, time: int) -> None:
self.expirations[key] = time
class _FakeScalarResult:
def __init__(self, values: list[object]) -> None:
self._values = values
def __iter__(self):
return iter(self._values)
def all(self) -> list[object]:
return self._values
@pytest.fixture
def mock_provider_entity():
mock_entity = Mock()
@@ -309,6 +353,7 @@ def test_get_configurations_uses_injected_runtime_and_adds_provider_aliases(mock
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
patch.object(manager, "_get_all_provider_credentials", return_value={}),
patch("core.provider_manager.ModelProviderFactory") as mock_factory_cls,
):
mock_factory_cls.return_value.get_providers.return_value = []
@@ -361,6 +406,7 @@ def test_get_configurations_binds_manager_runtime_to_provider_configuration(
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
patch.object(manager, "_get_all_provider_credentials", return_value={}),
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
patch.object(manager, "_to_model_settings", return_value=[]),
@@ -388,6 +434,7 @@ def test_get_configurations_reuses_cached_result_for_same_tenant(mocker: MockerF
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
patch.object(manager, "_get_all_provider_credentials", return_value={}),
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
patch.object(manager, "_to_model_settings", return_value=[]),
@@ -424,6 +471,7 @@ def test_clear_configurations_cache_rebuilds_requested_tenant(mocker: MockerFixt
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
patch.object(manager, "_get_all_provider_credentials", return_value={}),
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
patch.object(manager, "_to_model_settings", return_value=[]),
@@ -578,19 +626,162 @@ def test_get_all_providers_normalizes_provider_names_with_model_provider_id() ->
assert list(result[str(ModelProviderID("langgenius/gemini/google"))]) == [gemini_provider]
def test_get_all_providers_attaches_active_credentials() -> None:
provider = Provider(
tenant_id="tenant-id",
provider_name="openai",
provider_type=ProviderType.CUSTOM,
is_valid=True,
credential_id="credential-id",
)
provider.id = "provider-id"
credential = ProviderCredential(
tenant_id="tenant-id",
provider_name="openai",
credential_name="primary",
encrypted_config='{"api_key": "secret"}',
)
credential.id = "credential-id"
session = Mock()
session.scalars.side_effect = [
_FakeScalarResult([provider]),
_FakeScalarResult([credential]),
]
with (
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
):
result = ProviderManager._get_all_providers("tenant-id")
assert session.scalars.call_count == 2
assert result[str(ModelProviderID("openai"))][0].credential_name == "primary"
assert result[str(ModelProviderID("openai"))][0].encrypted_config == '{"api_key": "secret"}'
def test_invalidate_configurations_cache_bumps_selected_source_version() -> None:
fake_redis = _FakeRedis()
with patch("core.provider_manager.redis_client", fake_redis):
ProviderManager.invalidate_configurations_cache(
"tenant-id",
sources=(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,),
)
ProviderManager.invalidate_configurations_cache(
"tenant-id",
sources=(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,),
)
assert fake_redis.store["provider_configurations:tenant:tenant-id:source:provider_credentials:version"] == "2"
assert fake_redis.expirations["provider_configurations:tenant:tenant-id:source:provider_credentials:version"] == 360
assert "provider_configurations:tenant:tenant-id:source:provider_models:version" not in fake_redis.store
def test_provider_model_credentials_cache_returns_cache_entries() -> None:
fake_redis = _FakeRedis()
credential_record = SimpleNamespace(
id="credential-id",
provider_name="openai",
model_name="gpt-4",
model_type=ModelType.LLM,
credential_name="primary",
)
session = Mock()
session.scalars.return_value = [credential_record]
with (
patch("core.provider_manager.redis_client", fake_redis),
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
):
first = ProviderManager._get_all_provider_model_credentials("tenant-id")
second = ProviderManager._get_all_provider_model_credentials("tenant-id")
assert session.scalars.call_count == 1
version_key = "provider_configurations:tenant:tenant-id:source:provider_model_credentials:version"
assert fake_redis.expirations[version_key] == 360
assert first["openai"][0] is not credential_record
assert second["openai"][0].credential_name == "primary"
assert second["openai"][0].model_type == ModelType.LLM
def test_provider_configuration_cache_skips_write_when_version_changes_during_load() -> None:
fake_redis = _FakeRedis()
version_key = "provider_configurations:tenant:tenant-id:source:provider_model_credentials:version"
credential_record = SimpleNamespace(
id="credential-id",
provider_name="openai",
model_name="gpt-4",
model_type=ModelType.LLM,
credential_name="primary",
)
session = Mock()
def load_records(_stmt):
fake_redis.incr(version_key)
return [credential_record]
session.scalars.side_effect = load_records
with (
patch("core.provider_manager.redis_client", fake_redis),
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
):
result = ProviderManager._get_all_provider_model_credentials("tenant-id")
assert fake_redis.store[version_key] == "1"
assert "provider_configurations:tenant:tenant-id:source:provider_model_credentials:v:0" not in fake_redis.store
assert "provider_configurations:tenant:tenant-id:source:provider_model_credentials:v:1" not in fake_redis.store
assert result["openai"][0].credential_name == "primary"
@pytest.mark.parametrize(
"method_name",
[
"_get_all_provider_models",
"_get_all_provider_model_settings",
"_get_all_provider_model_credentials",
"_get_all_provider_credentials",
],
)
def test_provider_grouping_helpers_group_records_by_provider_name(method_name: str) -> None:
def build_record(provider_name: str, index: int):
match method_name:
case "_get_all_provider_models":
return SimpleNamespace(
id=f"model-{index}",
provider_name=provider_name,
model_name=f"model-{index}",
model_type=ModelType.LLM,
credential_id=None,
)
case "_get_all_provider_model_settings":
return SimpleNamespace(
provider_name=provider_name,
model_name=f"model-{index}",
model_type=ModelType.LLM,
enabled=True,
load_balancing_enabled=False,
)
case "_get_all_provider_model_credentials":
return SimpleNamespace(
id=f"model-credential-{index}",
provider_name=provider_name,
model_name=f"model-{index}",
model_type=ModelType.LLM,
credential_name=f"credential-{index}",
)
case "_get_all_provider_credentials":
return SimpleNamespace(
id=f"credential-{index}",
provider_name=provider_name,
credential_name=f"credential-{index}",
)
case _:
raise AssertionError(f"Unexpected method: {method_name}")
session = Mock()
openai_primary = SimpleNamespace(provider_name="openai")
openai_secondary = SimpleNamespace(provider_name="openai")
anthropic_record = SimpleNamespace(provider_name="anthropic")
openai_primary = build_record("openai", 1)
openai_secondary = build_record("openai", 2)
anthropic_record = build_record("anthropic", 3)
session.scalars.return_value = [openai_primary, openai_secondary, anthropic_record]
with (
@@ -598,14 +789,14 @@ def test_provider_grouping_helpers_group_records_by_provider_name(method_name: s
):
result = getattr(ProviderManager, method_name)("tenant-id")
assert list(result["openai"]) == [openai_primary, openai_secondary]
assert list(result["anthropic"]) == [anthropic_record]
assert [record.provider_name for record in result["openai"]] == ["openai", "openai"]
assert [record.provider_name for record in result["anthropic"]] == ["anthropic"]
def test_get_all_preferred_model_providers_returns_mapping_by_provider_name() -> None:
session = Mock()
openai_preference = SimpleNamespace(provider_name="openai")
anthropic_preference = SimpleNamespace(provider_name="anthropic")
openai_preference = SimpleNamespace(provider_name="openai", preferred_provider_type=ProviderType.SYSTEM)
anthropic_preference = SimpleNamespace(provider_name="anthropic", preferred_provider_type=ProviderType.CUSTOM)
session.scalars.return_value = [openai_preference, anthropic_preference]
with (
@@ -613,10 +804,8 @@ def test_get_all_preferred_model_providers_returns_mapping_by_provider_name() ->
):
result = ProviderManager._get_all_preferred_model_providers("tenant-id")
assert result == {
"openai": openai_preference,
"anthropic": anthropic_preference,
}
assert result["openai"].preferred_provider_type == ProviderType.SYSTEM
assert result["anthropic"].preferred_provider_type == ProviderType.CUSTOM
def test_get_all_provider_load_balancing_configs_returns_empty_when_cached_flag_is_disabled() -> None:
@@ -634,8 +823,30 @@ def test_get_all_provider_load_balancing_configs_returns_empty_when_cached_flag_
def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_configs() -> None:
session = Mock()
openai_config = SimpleNamespace(provider_name="openai")
anthropic_config = SimpleNamespace(provider_name="anthropic")
openai_config = SimpleNamespace(
id="lb-1",
tenant_id="tenant-id",
provider_name="openai",
model_name="gpt-4",
model_type=ModelType.LLM,
name="primary",
encrypted_config=None,
credential_id=None,
credential_source_type=None,
enabled=True,
)
anthropic_config = SimpleNamespace(
id="lb-2",
tenant_id="tenant-id",
provider_name="anthropic",
model_name="claude",
model_type=ModelType.LLM,
name="primary",
encrypted_config=None,
credential_id=None,
credential_source_type=None,
enabled=True,
)
session.scalars.return_value = [openai_config, anthropic_config]
with (
@@ -649,6 +860,6 @@ def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_conf
):
result = ProviderManager._get_all_provider_load_balancing_configs("tenant-id")
mock_setex.assert_called_once_with("tenant:tenant-id:model_load_balancing_enabled", 120, "True")
assert list(result["openai"]) == [openai_config]
assert list(result["anthropic"]) == [anthropic_config]
mock_setex.assert_any_call("tenant:tenant-id:model_load_balancing_enabled", 120, "True")
assert [record.provider_name for record in result["openai"]] == ["openai"]
assert [record.provider_name for record in result["anthropic"]] == ["anthropic"]
@@ -2,6 +2,7 @@ from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.ask_human import AskHumanToolResult
from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData
@@ -50,6 +51,13 @@ class FakeCredentialsProvider:
return {"api_key": "secret-key"}
@pytest.fixture(autouse=True)
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
def _restored_file(*, transfer_method: FileTransferMethod, reference: str) -> File:
return File(
type=FileType.DOCUMENT,
@@ -245,6 +253,23 @@ def _node(
)
def test_extract_variable_selector_to_variable_mapping_uses_frontend_agent_task_markers():
mapping = DifyAgentNode._extract_variable_selector_to_variable_mapping(
graph_config={},
node_id="agent-node",
node_data={
"agent_task": (
"Review {{#previous-node.report#}}, ignore {{#sys.query#}}, "
"ignore [§node_output:legacy-node.output:LEGACY§], then use {{#previous-node.report#}} again."
)
},
)
assert mapping == {
"agent-node.previous-node.report": ["previous-node", "report"],
}
def test_agent_node_run_maps_successful_agent_backend_run_to_node_result():
events = list(_node()._run())
@@ -1,3 +1,4 @@
import json
from dataclasses import replace
from typing import cast
@@ -9,6 +10,7 @@ from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LA
from clients.agent_backend import DIFY_EXECUTION_CONTEXT_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom
from core.workflow.file_reference import build_file_reference
from core.workflow.nodes.agent_v2.plugin_tools_builder import WorkflowAgentPluginToolsBuilder
from core.workflow.nodes.agent_v2.runtime_request_builder import (
WorkflowAgentRuntimeBuildContext,
@@ -16,7 +18,8 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
WorkflowAgentRuntimeRequestBuildError,
build_shell_layer_config,
)
from graphon.variables.segments import StringSegment
from graphon.file import File, FileTransferMethod, FileType
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentSoulConfig,
@@ -36,6 +39,13 @@ class FakeCredentialsProvider:
return {"api_key": "secret-key"}
@pytest.fixture(autouse=True)
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
class CapturingCredentialsProvider:
def __init__(self) -> None:
self.provider_name: str | None = None
@@ -165,6 +175,27 @@ def _context() -> WorkflowAgentRuntimeBuildContext:
)
def _request_layers(result) -> dict[str, dict[str, object]]:
dumped = result.request.model_dump(mode="json")
return {layer["name"]: layer for layer in dumped["composition"]["layers"]}
def _workflow_user_prompt(result) -> str:
from clients.agent_backend import WORKFLOW_USER_PROMPT_LAYER_ID
layer = _request_layers(result)[WORKFLOW_USER_PROMPT_LAYER_ID]
return cast(str, layer["config"]["user"])
def _previous_node_prompt_payload(result, selector: str) -> object:
prefix = f" - {selector}: "
user_prompt = _workflow_user_prompt(result)
for line in user_prompt.splitlines():
if line.startswith(prefix):
return json.loads(line.removeprefix(prefix))
raise AssertionError(f"missing prompt payload for {selector}")
def test_builds_create_run_request_from_agent_soul_and_node_job():
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context())
@@ -177,11 +208,14 @@ def test_builds_create_run_request_from_agent_soul_and_node_job():
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "debugger"
assert dumped["idempotency_key"] == "run-1:node-exec-1"
assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are careful."
assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Use the previous output."
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
assert dumped["composition"]["layers"][1]["config"]["user"] == "Use the previous output."
assert "Agent task for this workflow run:" not in dumped["composition"]["layers"][2]["config"]["user"]
assert "User query: Summarize the report." in dumped["composition"]["layers"][2]["config"]["user"]
assert "Previous node outputs:" not in dumped["composition"]["layers"][2]["config"]["user"]
assert dumped["composition"]["layers"][-1]["config"]["json_schema"]["properties"]["summary"]["type"] == "string"
assert DIFY_AGENT_HISTORY_LAYER_ID in layers
assert result.redacted_request["composition"]["layers"][5]["config"]["credentials"] == "[REDACTED]"
redacted_layers = {layer["name"]: layer for layer in result.redacted_request["composition"]["layers"]}
assert redacted_layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["credentials"] == "[REDACTED]"
def test_normalizes_langgenius_model_provider_for_agent_backend_transport():
@@ -262,7 +296,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
assert report_schema["oneOf"][3]["required"] == ["transfer_method", "url"]
assert output_schema["properties"]["confidence"]["type"] == "number"
assert output_schema["required"] == ["report"]
assert dumped["composition"]["layers"][5]["config"]["model_settings"] == {"temperature": 0.2}
assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_settings"] == {"temperature": 0.2}
assert result.metadata["runtime_support"]["reserved_status"]["tools.dify_tools"] == "supported_when_config_valid"
assert result.metadata["runtime_support"]["reserved_status"]["tools.cli_tools"] == "supported_by_shell_bootstrap"
assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == []
@@ -512,12 +546,55 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
"model": "gpt-test",
},
"knowledge": {
"datasets": [{"id": "dataset-1"}, {"id": " "}, {"id": "dataset-2"}],
"query_config": {
"top_k": 6,
"score_threshold": 0.4,
"score_threshold_enabled": True,
},
"sets": [
{
"id": "support",
"name": "Support KB",
"description": "Support content",
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {
"mode": "multiple",
"top_k": 6,
"score_threshold": 0.4,
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
},
"metadata_filtering": {
"mode": "manual",
"conditions": {
"logical_operator": "and",
"conditions": [
{"name": "category", "comparison_operator": "contains", "value": "auth"}
],
},
},
},
{
"id": "release",
"name": "Release Notes",
"datasets": [{"id": "dataset-3"}],
"query": {"mode": "user_query", "value": "release notes"},
"retrieval": {
"mode": "single",
"model": {
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {"temperature": 0.2},
},
},
"metadata_filtering": {
"mode": "automatic",
"model_config": {
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {},
},
},
},
],
},
}
),
@@ -531,25 +608,73 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
knowledge_layer = layers["knowledge"]
assert knowledge_layer["type"] == "dify.knowledge_base"
assert knowledge_layer["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
assert knowledge_layer["config"] == {
"dataset_ids": ["dataset-1", "dataset-2"],
"retrieval": {
"mode": "multiple",
"top_k": 6,
"score_threshold": 0.4,
"reranking_mode": "reranking_model",
"reranking_enable": True,
"reranking_model": None,
"weights": None,
"model": None,
assert knowledge_layer["config"]["sets"] == [
{
"id": "support",
"name": "Support KB",
"description": "Support content",
"datasets": [
{"id": "dataset-1", "name": None, "description": None},
{"id": "dataset-2", "name": None, "description": None},
],
"query": {"mode": "generated_query", "value": None},
"retrieval": {
"mode": "multiple",
"top_k": 6,
"score_threshold": 0.4,
"reranking_mode": "reranking_model",
"reranking_enable": True,
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
"model": None,
},
"metadata_filtering": {
"mode": "manual",
"metadata_model_config": None,
"conditions": {
"logical_operator": "and",
"conditions": [{"name": "category", "comparison_operator": "contains", "value": "auth"}],
},
},
},
"metadata_filtering": {"mode": "disabled", "metadata_model_config": None, "conditions": None},
"max_result_content_chars": 2000,
"max_observation_chars": 12000,
}
{
"id": "release",
"name": "Release Notes",
"description": None,
"datasets": [{"id": "dataset-3", "name": None, "description": None}],
"query": {"mode": "user_query", "value": "release notes"},
"retrieval": {
"mode": "single",
"top_k": None,
"score_threshold": 0.0,
"reranking_mode": "reranking_model",
"reranking_enable": True,
"reranking_model": None,
"weights": None,
"model": {
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {"temperature": 0.2},
},
},
"metadata_filtering": {
"mode": "automatic",
"metadata_model_config": {
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {},
},
"conditions": None,
},
},
]
assert knowledge_layer["config"]["max_result_content_chars"] == 2000
assert knowledge_layer["config"]["max_observation_chars"] == 12000
def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits_it():
def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero():
context = _context()
snapshot = AgentConfigSnapshot(
id="snapshot-1",
@@ -565,8 +690,19 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits
"model": "gpt-test",
},
"knowledge": {
"datasets": [{"id": "dataset-1"}],
"query_config": {},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {
"mode": "multiple",
"top_k": 4,
"score_threshold": None,
},
}
],
},
}
),
@@ -577,10 +713,10 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits
dumped = result.request.model_dump(mode="json")
knowledge_layer = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "knowledge")
assert knowledge_layer["config"]["retrieval"]["top_k"] == 4
assert knowledge_layer["config"]["sets"][0]["retrieval"]["score_threshold"] == 0.0
def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids():
def test_build_skips_knowledge_layer_when_agent_soul_has_no_sets():
context = _context()
snapshot = AgentConfigSnapshot(
id="snapshot-1",
@@ -595,9 +731,7 @@ def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids():
"model_provider": "openai",
"model": "gpt-test",
},
"knowledge": {
"datasets": [{"id": " "}, {}],
},
"knowledge": {"sets": []},
}
),
)
@@ -793,11 +927,8 @@ def test_effective_declared_outputs_passthrough_when_user_declared():
def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
"""ENG-616: slash-menu mention tokens expand to canonical names; node_output
mentions expand to the reference name only (the value stays in the Workflow
context user prompt), and no ``[§§]`` marker leaks into the request."""
import json
"""ENG-616: soul/output mentions expand, while frontend workflow markers stay
literal in the workflow task layer and resolve under workflow context."""
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
prompt={"system_prompt": "Careful. Ask [§human:c-1:EMAIL · DAVE§] when unsure."},
@@ -807,26 +938,189 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": (
"Read [§node_output:previous-node.text:PREV/text§] and produce [§output:summary§]. "
"Read {{#previous-node.text#}} and produce [§output:summary§]. "
"Unknown [§knowledge:gone:旧手册§] degrades."
),
"previous_node_output_refs": [
{"selector": ["previous-node", "text"], "name": "PREV/text"},
],
"declared_outputs": [{"name": "summary", "type": "string"}],
}
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
dumped = result.request.model_dump(mode="json")
assert dumped["composition"]["layers"][0]["config"]["prefix"] == ("Careful. Ask EMAIL · David Hayes when unsure.")
assert dumped["composition"]["layers"][1]["config"]["prefix"] == (
"Read PREV/text and produce summary (string). Unknown 旧手册 degrades."
layers = _request_layers(result)
agent_soul_prompt = layers["agent_soul_prompt"]["config"]["prefix"]
job_prompt = layers["workflow_node_job_prompt"]["config"]["user"]
assert agent_soul_prompt == ("Careful. Ask EMAIL · David Hayes when unsure.")
assert job_prompt == "Read {{#previous-node.text#}} and produce summary (string). Unknown 旧手册 degrades."
user_prompt = _workflow_user_prompt(result)
assert "Agent task for this workflow run:" not in user_prompt
assert "Previous result" in user_prompt
for prompt_text in (agent_soul_prompt, job_prompt, user_prompt):
assert "" not in prompt_text
assert "{{#" in job_prompt
assert "{{#" not in agent_soul_prompt
assert "{{#" not in user_prompt
def test_previous_node_file_output_uses_agent_stub_download_mapping_in_workflow_context():
file_reference = build_file_reference(record_id="tool-file-1")
class FileVariablePool(FakeVariablePool):
def get(self, selector):
if list(selector) == ["previous-node", "report"]:
return FileSegment(
value=File(
type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.TOOL_FILE,
reference=file_reference,
remote_url=None,
filename="report.pdf",
extension=".pdf",
mime_type="application/pdf",
size=12,
)
)
return super().get(selector)
context = replace(_context(), variable_pool=FileVariablePool())
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "Review {{#previous-node.report#}} before responding.",
}
)
# the value still rides the Workflow context block, not the job prompt
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
assert "" not in json.dumps(dumped["composition"]["layers"][:3])
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
assert _request_layers(result)["workflow_node_job_prompt"]["config"]["user"] == (
"Review {{#previous-node.report#}} before responding."
)
assert _previous_node_prompt_payload(result, "previous-node.report") == {
"transfer_method": "tool_file",
"reference": file_reference,
}
def test_scalar_previous_node_output_appears_in_workflow_context_section():
context = _context()
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "Review {{#previous-node.text#}} before responding.",
}
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
user_prompt = _workflow_user_prompt(result)
assert "- Previous node outputs:" in user_prompt
assert " - previous-node.text: Previous result" in user_prompt
def test_stale_previous_node_refs_are_ignored_when_workflow_prompt_has_no_frontend_markers():
context = _context()
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "Review the current request without upstream context.",
"previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}],
}
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
assert _request_layers(result)["workflow_node_job_prompt"]["config"]["user"] == (
"Review the current request without upstream context."
)
assert "Previous node outputs:" not in _workflow_user_prompt(result)
def test_previous_node_file_array_uses_agent_stub_download_mappings_in_workflow_context():
first_reference = build_file_reference(record_id="tool-file-1")
second_reference = build_file_reference(record_id="tool-file-2")
class FileArrayVariablePool(FakeVariablePool):
def get(self, selector):
if list(selector) == ["previous-node", "attachments"]:
return ArrayFileSegment(
value=[
File(
type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.TOOL_FILE,
reference=first_reference,
remote_url=None,
filename="first.pdf",
extension=".pdf",
mime_type="application/pdf",
size=12,
),
File(
type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.REMOTE_URL,
reference=None,
remote_url="https://example.com/second.pdf",
filename="second.pdf",
extension=".pdf",
mime_type="application/pdf",
size=12,
),
]
)
return super().get(selector)
context = replace(_context(), variable_pool=FileArrayVariablePool())
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "Inspect {{#previous-node.attachments#}}",
}
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
assert _previous_node_prompt_payload(result, "previous-node.attachments") == [
{
"transfer_method": "tool_file",
"reference": first_reference,
},
{
"transfer_method": "remote_url",
"url": "https://example.com/second.pdf",
},
]
def test_previous_node_remote_url_file_mapping_is_not_truncated_in_workflow_context():
remote_url = "https://example.com/" + ("a" * 2100) + ".pdf"
class LongRemoteUrlVariablePool(FakeVariablePool):
def get(self, selector):
if list(selector) == ["previous-node", "report"]:
return FileSegment(
value=File(
type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.REMOTE_URL,
reference=None,
remote_url=remote_url,
filename="report.pdf",
extension=".pdf",
mime_type="application/pdf",
size=12,
)
)
return super().get(selector)
context = replace(_context(), variable_pool=LongRemoteUrlVariablePool())
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "Use {{#previous-node.report#}}",
}
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
assert _previous_node_prompt_payload(result, "previous-node.report") == {
"transfer_method": "remote_url",
"url": remote_url,
}
assert "...[truncated]" not in _workflow_user_prompt(result)
# ── ENG-623: dify.drive declaration layer ─────────────────────────────────────
@@ -931,10 +1225,9 @@ def test_workflow_run_request_contains_drive_layer_with_empty_catalog(monkeypatc
"mentioned_skill_keys": [],
"mentioned_file_keys": [],
}
assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"drive": "drive",
}
assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] == "agent-agent-1"
assert layers["drive"]["deps"] == {"shell": DIFY_SHELL_LAYER_ID}
def test_build_drive_layer_config_requires_agent_identity():
@@ -960,11 +1253,12 @@ def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch
dumped = result.request.model_dump(mode="json")
layer_names = [layer["name"] for layer in dumped["composition"]["layers"]]
assert "drive" in layer_names
# injected right after execution_context, before history/llm
assert layer_names.index("drive") == layer_names.index("execution_context") + 1
# shell enters first; drive uses that shell to materialize mentioned targets.
assert layer_names.index(DIFY_SHELL_LAYER_ID) == layer_names.index("execution_context") + 1
assert layer_names.index("drive") == layer_names.index(DIFY_SHELL_LAYER_ID) + 1
drive = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "drive")
assert drive["type"] == "dify.drive"
assert drive["deps"] == {"execution_context": "execution_context"}
assert drive["deps"] == {"shell": DIFY_SHELL_LAYER_ID}
assert drive["config"]["drive_ref"] == "agent-agent-1"
assert drive["config"]["skills"] == [
{
@@ -1031,7 +1325,10 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
assert "" not in soul_prompt.config.prefix
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled():
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()
@@ -1094,7 +1391,15 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu
soul = AgentSoulConfig.model_validate(
{
"knowledge": {
"datasets": [{"id": "dataset-1", "name": "Product Docs"}],
"sets": [
{
"id": "product",
"name": "Product Docs",
"datasets": [{"id": "dataset-1", "name": "Product Docs"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
],
}
}
)
@@ -1106,13 +1411,13 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu
assert all("knowledge" not in w["section"] for w in manifest["unsupported_runtime_warnings"])
def test_feature_manifest_treats_blank_knowledge_dataset_ids_as_not_configured():
def test_feature_manifest_treats_empty_knowledge_sets_as_not_configured():
from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest
soul = AgentSoulConfig.model_validate(
{
"knowledge": {
"datasets": [{"id": " "}, {}],
"sets": [],
}
}
)
@@ -55,6 +55,33 @@ def _snapshot() -> AgentConfigSnapshot:
)
def _snapshot_with_knowledge_dataset(dataset_id: str) -> AgentConfigSnapshot:
return AgentConfigSnapshot(
id="snapshot-1",
tenant_id="tenant-1",
agent_id="agent-1",
version=1,
config_snapshot=AgentSoulConfig(
model=AgentSoulModelConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="gpt-test",
),
knowledge={
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": dataset_id}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
},
),
)
def _graph(edges: list[dict]) -> dict:
return {
"nodes": [
@@ -515,6 +542,35 @@ def test_publish_validation_rejects_missing_file_ref():
)
def test_publish_validation_rejects_missing_or_out_of_scope_knowledge_datasets(
monkeypatch: pytest.MonkeyPatch,
):
dataset_id = "550e8400-e29b-41d4-a716-446655440000"
node_job = WorkflowNodeJobConfig.model_validate({})
snapshot = _snapshot_with_knowledge_dataset(dataset_id)
session = Mock()
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
captured = {}
def fake_get_datasets_by_ids(ids, tenant_id):
captured["ids"] = ids
captured["tenant_id"] = tenant_id
return [], 0
import services.dataset_service as dataset_service_module
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
with pytest.raises(WorkflowAgentNodeValidationError, match=dataset_id):
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
)
assert captured == {"ids": [dataset_id], "tenant_id": "tenant-1"}
def test_publish_validation_accepts_tool_node_agentic_manual_mode():
session = Mock()
@@ -64,7 +64,7 @@ def _run_migration_step(module: object, engine: sa.Engine, step_name: str) -> No
module.op = original_op
def test_upgrade_adds_skill_columns_and_index_and_strips_snapshot_data() -> None:
def test_upgrade_adds_skill_columns_and_index_and_preserves_snapshot_data() -> None:
engine = sa.create_engine("sqlite:///:memory:")
_create_pre_upgrade_schema(engine)
snapshot = {
@@ -91,7 +91,7 @@ def test_upgrade_adds_skill_columns_and_index_and_strips_snapshot_data() -> None
sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"),
{"id": "snap-1"},
).scalar_one()
assert "skills_files" not in json.loads(stored_snapshot)
assert json.loads(stored_snapshot) == snapshot
def test_downgrade_drops_skill_columns_and_index_without_reconstructing_legacy_data() -> None:
+23
View File
@@ -7,6 +7,8 @@ from sqlalchemy.exc import IntegrityError
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@@ -34,6 +36,9 @@ def test_agent_enums_match_prd_boundaries():
assert AgentStatus.ARCHIVED.value == "archived"
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version"
assert AgentConfigRevisionOperation.RESTORE_VERSION.value == "restore_version"
assert AgentConfigRevisionOperation.PUBLISH_DRAFT.value == "publish_draft"
assert AgentConfigDraftType.DRAFT.value == "draft"
assert AgentConfigDraftType.DEBUG_BUILD.value == "debug_build"
assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent"
assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent"
@@ -136,6 +141,23 @@ def test_current_snapshot_stores_agent_soul_snapshot_as_long_text_json():
assert version.config_snapshot_dict["env"]["secret_refs"][0]["provider_credential_id"] == "cred-1"
def test_agent_config_draft_stores_editable_agent_soul_as_long_text_json():
config_snapshot = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "draft prompt"}})
draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
draft_owner_key="",
config_snapshot=config_snapshot,
)
config_snapshot_column = AgentConfigDraft.__table__.c.config_snapshot
assert isinstance(config_snapshot_column.type, JSONModelColumn)
assert config_snapshot_column.server_default is None
assert draft.config_snapshot_dict == config_snapshot.model_dump(mode="json")
assert draft.config_snapshot_dict["prompt"]["system_prompt"] == "draft prompt"
def test_workflow_binding_stores_node_job_config_separately_from_agent_soul():
node_job_config = {
"schema_version": 1,
@@ -166,6 +188,7 @@ def test_long_text_columns_do_not_use_mysql_incompatible_server_defaults():
assert isinstance(column.type, LongText)
assert column.server_default is None
assert AgentConfigSnapshot.__table__.c.config_snapshot.server_default is None
assert AgentConfigDraft.__table__.c.config_snapshot.server_default is None
assert WorkflowAgentNodeBinding.__table__.c.node_job_config.server_default is None
@@ -1,4 +1,5 @@
import pytest
from pydantic import ValidationError
from models.agent_config_entities import AgentKnowledgeQueryMode, AgentSoulModelConfig, DeclaredOutputType
from services.agent.composer_service import AgentComposerService
@@ -26,6 +27,24 @@ def test_workflow_variant_rejects_agent_app_only_fields():
)
def test_workflow_variant_accepts_agent_soul_files_section():
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.WORKFLOW,
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY,
"agent_soul": {
"schema_version": 1,
"prompt": {"system_prompt": "jjjj"},
"files": {"skills": [], "files": []},
},
}
)
assert payload.agent_soul is not None
assert payload.agent_soul.files.skills == []
assert payload.agent_soul.files.files == []
def test_agent_app_variant_rejects_workflow_node_job():
with pytest.raises(ValueError):
ComposerSavePayload.model_validate(
@@ -131,14 +150,144 @@ def test_knowledge_query_mode_uses_stable_backend_enums():
config = AgentSoulConfig.model_validate(
{
"knowledge": {
"datasets": [{"dataset_id": "dataset-1"}],
"query_mode": "generated_query",
"query_config": {"generation_prompt": "Create a retrieval query."},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
],
}
}
)
assert config.knowledge.query_mode == AgentKnowledgeQueryMode.GENERATED_QUERY
assert config.knowledge.sets[0].query.mode == AgentKnowledgeQueryMode.GENERATED_QUERY
@pytest.mark.parametrize(
("knowledge_payload", "match"),
[
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
{
"id": "support",
"name": "Billing KB",
"datasets": [{"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
]
},
"knowledge set ids must be unique",
),
(
{
"sets": [
{
"id": "support",
"name": "Shared KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
{
"id": "billing",
"name": "Shared KB",
"datasets": [{"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
]
},
"knowledge set names must be unique",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}, {"id": " dataset-1 "}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
]
},
"knowledge set dataset ids must be unique",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "user_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
]
},
"knowledge query.value is required for user_query mode",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "single"},
},
]
},
"knowledge retrieval.model is required for single mode",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
"metadata_filtering": {"mode": "automatic"},
},
]
},
"metadata_filtering.model_config is required for automatic mode",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
"metadata_filtering": {"mode": "manual"},
},
]
},
"metadata_filtering.conditions is required for manual mode",
),
],
)
def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str):
with pytest.raises(ValidationError, match=match):
AgentSoulConfig.model_validate({"knowledge": knowledge_payload})
def test_agent_soul_model_config_is_first_class_without_credentials():

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