Compare commits

..
Author SHA1 Message Date
Xiyuan ChenGareArcautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
64e29f2c9c fix(api): register rbac-migrate-dataset-permissions CLI command (#38204)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-30 01:36:46 -07:00
GareArc a70fcd315a Revert "feat(agent-v2): sync nightly updates to main (2026-06-25) (#37915)"
This reverts commit 52c106b532.
2026-06-30 01:20:58 -07:00
非法操作andGareArc f12602c046 fix: editor can view the logs (#38165) 2026-06-30 00:18:26 -07:00
wangxiaoleiandGareArc d2e1f08026 feat: support dataset permission migrate to rbac (#38166) 2026-06-30 00:00:36 -07:00
Wu TianweiandGareArc c49b4e20bf fix: update documentation links in permission set and role modals (#38188) 2026-06-29 23:26:33 -07:00
Wu TianweiandGareArc 85cc5d16be chore(i18n): update role management permission keys for multiple languages (#38186) 2026-06-29 23:26:33 -07:00
wangxiaoleiandGareArc 2c8869f688 perf: make command rbac-migrate-member-roles use less mem and make it… (#38151) 2026-06-29 23:26:32 -07:00
盐粒 YanliGareArcJoelyyhYansong Zhangautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
3afd571743 feat(agent-v2): sync nightly updates to main (2026-06-25) (#37915)
Co-authored-by: Joel <[email protected]>
Co-authored-by: yyh <[email protected]>
Co-authored-by: Yansong Zhang <[email protected]>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-29 04:05:09 -07:00
非法操作andGareArc ddc83de791 fix: editor should not query billing subscriptions (#38157) 2026-06-29 03:10:48 -07:00
非法操作andGareArc b59125f2f0 fix: agent app log detail modal not display well (#38014) 2026-06-29 03:10:45 -07:00
Xiyuan ChenandGareArc 6292119848 fix(api): wire dedicated timeout into inner RBAC requests (#38150) 2026-06-29 00:43:36 -07:00
非法操作andGareArc d59c76bfdc fix: plugin installation task popover layout when some failed too long (#38000) 2026-06-28 19:41:36 -07:00
4c591d51cb fix(web): keep HITL input-field save button visible in the edit dialog (#38007)
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-25 23:42:33 -07:00
-LAN-GareArcautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
370fc1e81f fix(api): require edit access for trace config changes (#37973)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 19:37:33 -07:00
734 changed files with 10581 additions and 32427 deletions
+2 -12
View File
@@ -12,7 +12,6 @@ Use this as the component decision guide for Dify web. Existing code is referenc
| Question | Default | Promote or extract only when |
| --- | --- | --- |
| Where should code live? | Keep it local to the feature workflow, route, or owner. | Multiple verticals need the same stable primitive. |
| How should route/tab folders be named? | Match the current route segment, tab name, or user-visible surface. | Keep a historical or broader parent only when it still owns multiple surfaces. |
| Who owns state, data, and handlers? | The lowest component that uses them. | A parent coordinates shared loading, errors, empty UI, selection, submission, navigation, or one consistent snapshot. |
| Should this become Jotai state? | Keep synchronous UI/form state in component or DOM state. | Siblings need one source of truth, the value drives atoms, or scoped workflow state must survive hidden/unmounted steps. |
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
@@ -24,9 +23,7 @@ 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/*`.
- Group feature code by workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- 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.
@@ -35,8 +32,6 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- State-heavy wizards, drawers, modals, and secondary workflows can be a small feature surface: an entry file, one feature-local state file when Jotai is actually needed, and shallow `ui/` owners that match real visual regions.
- The entry file handles route integration, provider wiring, close behavior, and surface mounting. The composition owner handles high-level workflow branching. The closest visual owner handles section branching.
- When a page or tab maps to a route segment, name its feature folder after that route/tab surface instead of a stale parent grouping. Remove misleading intermediate folders when only one surface remains.
- When a tab folder grows into several independent sections or action areas, split the first level by product/visual owners. Keep the root for the entry component and cross-owner state, colocate tests with the owner folder, and put truly shared local UI under a specifically named `components/` file.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data; TanStack Query deduplicates and shares cache.
- Pass stable domain identity across boundaries. Do not forward derived presentation state when the receiver can derive it from its own data source.
- A component that owns a visual surface should also own data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
@@ -51,7 +46,6 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
- Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit.
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
@@ -66,10 +60,8 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Type component signatures directly; do not use `FC` or `React.FC`.
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
@@ -91,13 +83,11 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape.
- Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- If a generated query input comes from an atom, including a route-identity bridge atom, keep the query in `atomWithQuery`; do not unwrap the atom in a component just to call `useQuery`.
- Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms.
- In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object.
- For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`.
- When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options.
- For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`.
- Do not extract generated `queryOptions(...)` into a helper solely to share input construction; extract only when prefetch/render must share exact options or the helper owns real domain behavior.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally.
- For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache.
@@ -106,7 +96,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
## Boundaries And Overlays
- Use the first level below a page or tab to organize independent page sections when it adds structure or the root folder becomes noisy. This layer is layout/semantic first, not automatically the data owner.
- Use the first level below a page or tab to organize independent page sections when it adds structure. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component when hidden content would obscure the parent.
-2
View File
@@ -53,8 +53,6 @@ jobs:
- name: Run Type Checks
if: steps.changed-files.outputs.any_changed == 'true'
env:
PYREFLY_OUTPUT_FORMAT: github
run: make type-check-core
- name: Dotenv check
+44 -55
View File
@@ -78,22 +78,11 @@ def _filter_snapshot_to_specs(
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
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})
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
class AgentBackendModelConfig(BaseModel):
@@ -274,29 +263,14 @@ 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): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
# Drive Skills & Files declaration (dify.drive): a config-only index;
# the agent pulls listed entries through the back proxy by drive_ref.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps=_drive_layer_deps(),
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.drive_config,
)
@@ -338,7 +312,7 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.knowledge is not None and run_input.knowledge.sets:
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
layers.append(
RunLayerSpec(
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
@@ -362,6 +336,21 @@ 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(
@@ -456,7 +445,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(user=run_input.workflow_node_job_prompt),
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
),
RunLayerSpec(
name=WORKFLOW_USER_PROMPT_LAYER_ID,
@@ -473,29 +462,14 @@ 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): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
# Drive Skills & Files declaration (dify.drive): a config-only index;
# the agent pulls listed entries through the back proxy by drive_ref.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps=_drive_layer_deps(),
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.drive_config,
)
@@ -539,7 +513,7 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.knowledge is not None and run_input.knowledge.sets:
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
layers.append(
RunLayerSpec(
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
@@ -563,6 +537,21 @@ 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(
+2 -1
View File
@@ -22,7 +22,7 @@ from .plugin import (
setup_system_trigger_oauth_client,
transform_datasource_credentials,
)
from .rbac import migrate_member_roles_to_rbac
from .rbac import migrate_dataset_permissions_to_rbac, migrate_member_roles_to_rbac
from .retention import (
archive_workflow_runs,
archive_workflow_runs_plan,
@@ -76,6 +76,7 @@ __all__ = [
"legacy_model_types",
"migrate_annotation_vector_database",
"migrate_data_for_plugin",
"migrate_dataset_permissions_to_rbac",
"migrate_knowledge_vector_database",
"migrate_member_roles_to_rbac",
"migrate_oss",
+2
View File
@@ -7,6 +7,7 @@ from typing import cast
import click
from commands.rbac import migrate_dataset_permissions_to_rbac
from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from services.legacy_model_type_migration import (
@@ -177,3 +178,4 @@ def legacy_model_types(
data_migrate.add_command(legacy_model_types)
data_migrate.add_command(migrate_dataset_permissions_to_rbac)
+438 -66
View File
@@ -1,11 +1,55 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed
import click
from sqlalchemy import select
from configs import dify_config
from core.db.session_factory import session_factory
from models import TenantAccountJoin, TenantAccountRole
from services.enterprise.rbac_service import ListOption, RBACService
from core.rbac import RBACResourceWhitelistScope
from models import Dataset, DatasetPermission, DatasetPermissionEnum, TenantAccountJoin, TenantAccountRole
from services.enterprise.rbac_service import ListOption, RBACService, ReplaceMemberBindings, ReplaceUserAccessPolicies
_RBAC_DEFAULT_ACCESS_POLICY_ID = "default"
_LEGACY_ROLE_TO_BUILTIN_TAG = {
TenantAccountRole.OWNER.value: "owner",
TenantAccountRole.ADMIN.value: "admin",
TenantAccountRole.EDITOR.value: "editor",
TenantAccountRole.NORMAL.value: "normal",
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
}
def _resolve_builtin_role_ids(tenant_id: str, operator_account_id: str) -> dict[str, str]:
"""Resolve every legacy workspace role to the current tenant's builtin RBAC role id.
The migration replays the old `TenantAccountJoin.role` values onto the
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
identified by runtime ids, so the command must look them up per tenant.
"""
roles = RBACService.Roles.list(
tenant_id=tenant_id,
account_id=operator_account_id,
options=ListOption(page_number=1, results_per_page=100),
).data
role_id_by_tag = {
role.role_tag: role.id
for role in roles
if role.is_builtin and role.category == "global_system_default" and role.role_tag
}
resolved: dict[str, str] = {}
for legacy_role, expected_builtin_tag in _LEGACY_ROLE_TO_BUILTIN_TAG.items():
role_id = role_id_by_tag.get(expected_builtin_tag)
if expected_builtin_tag == "dataset_operator" and not dify_config.DATASET_OPERATOR_ENABLED:
continue
if not role_id:
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
resolved[legacy_role] = role_id
return resolved
def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str:
@@ -15,26 +59,86 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
identified by runtime ids, so the command must look them up per tenant.
"""
expected_builtin_tag = {
TenantAccountRole.OWNER.value: "owner",
TenantAccountRole.ADMIN.value: "admin",
TenantAccountRole.EDITOR.value: "editor",
TenantAccountRole.NORMAL.value: "normal",
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
}.get(legacy_role)
if not expected_builtin_tag:
if legacy_role not in _LEGACY_ROLE_TO_BUILTIN_TAG:
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
roles = RBACService.Roles.list(
return _resolve_builtin_role_ids(tenant_id, operator_account_id)[legacy_role]
def _iter_tenant_member_batches(
tenant_id: str | None,
*,
db_batch_size: int,
api_batch_size: int,
) -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
"""Yield legacy member roles in tenant-scoped API-sized batches.
Rows are projected to primitive values and streamed from the database, so
the command never materializes every TenantAccountJoin ORM object. The
iterator only keeps one tenant's API-sized batches in memory while it
finds that tenant's owner account.
"""
with session_factory.create_session() as session:
stmt = (
select(TenantAccountJoin.tenant_id, TenantAccountJoin.account_id, TenantAccountJoin.role)
.order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
.execution_options(yield_per=db_batch_size)
)
if tenant_id:
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
current_tenant_id: str | None = None
owner_account_id: str | None = None
batches: list[list[tuple[str, str]]] = []
batch: list[tuple[str, str]] = []
def flush_current_tenant() -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
if current_tenant_id is None:
return
if batch:
batches.append(batch.copy())
if not owner_account_id:
raise ValueError(f"Workspace owner not found for tenant={current_tenant_id}")
for item in batches:
yield current_tenant_id, owner_account_id, item
for row in session.execute(stmt):
workspace_id = str(row.tenant_id)
if current_tenant_id is not None and workspace_id != current_tenant_id:
yield from flush_current_tenant()
owner_account_id = None
batches = []
batch = []
current_tenant_id = workspace_id
account_id = str(row.account_id)
role = str(row.role)
if role == TenantAccountRole.OWNER.value:
owner_account_id = account_id
batch.append((account_id, role))
if len(batch) >= api_batch_size:
batches.append(batch)
batch = []
yield from flush_current_tenant()
def _member_already_has_role(current_roles_by_account_id: dict[str, set[str]], account_id: str, role_id: str) -> bool:
return current_roles_by_account_id.get(account_id) == {role_id}
def _replace_member_role(
tenant_id: str,
operator_account_id: str,
member_account_id: str,
role_id: str,
) -> str:
RBACService.MemberRoles.replace(
tenant_id=tenant_id,
account_id=operator_account_id,
options=ListOption(page_number=1, results_per_page=100),
).data
for role in roles:
if role.is_builtin and role.category == "global_system_default" and role.role_tag == expected_builtin_tag:
return str(role.id)
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
member_account_id=member_account_id,
role_ids=[role_id],
)
return member_account_id
@click.command(
@@ -42,7 +146,16 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
)
@click.option("--tenant-id", help="Only migrate a single workspace.")
@click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.")
def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
@click.option("--db-batch-size", default=5000, show_default=True, help="Rows fetched per database batch.")
@click.option("--api-batch-size", default=200, show_default=True, help="Members checked per RBAC batch_get call.")
@click.option("--workers", default=1, show_default=True, help="Concurrent member role replace calls per tenant batch.")
def migrate_member_roles_to_rbac(
tenant_id: str | None,
dry_run: bool,
db_batch_size: int,
api_batch_size: int,
workers: int,
) -> None:
"""Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data.
This is an offline migration command for workspaces that already have
@@ -50,63 +163,322 @@ def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
member-role binding store.
"""
click.echo(click.style("Starting RBAC member-role migration.", fg="green"))
if workers < 1:
raise click.BadParameter("workers must be >= 1", param_hint="--workers")
with session_factory.create_session() as session:
stmt = select(TenantAccountJoin).order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
if tenant_id:
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
tenant_count = 0
scanned_count = 0
skipped_count = 0
migrated_count = 0
current_tenant_id: str | None = None
role_ids_by_legacy_role: dict[str, str] = {}
joins = list(session.scalars(stmt).all())
for workspace_id, owner_account_id, batch in _iter_tenant_member_batches(
tenant_id,
db_batch_size=db_batch_size,
api_batch_size=api_batch_size,
):
scanned_count += len(batch)
if workspace_id != current_tenant_id:
tenant_count += 1
current_tenant_id = workspace_id
role_ids_by_legacy_role = _resolve_builtin_role_ids(workspace_id, owner_account_id)
click.echo(f"tenant={workspace_id}")
if not joins:
current_roles_by_account_id: dict[str, set[str]] = {}
if not dry_run:
current_roles = RBACService.MemberRoles.batch_get(
tenant_id=workspace_id,
account_id=owner_account_id,
member_account_ids=[account_id for account_id, _ in batch],
)
current_roles_by_account_id = {
item.account_id: {str(role.id) for role in item.roles} for item in current_roles
}
replace_jobs: list[tuple[str, str]] = []
for member_account_id, legacy_role in batch:
resolved_role_id = role_ids_by_legacy_role.get(legacy_role)
if not resolved_role_id:
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
if dry_run:
click.echo(
f"tenant={workspace_id} member={member_account_id} "
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
)
continue
if _member_already_has_role(current_roles_by_account_id, member_account_id, resolved_role_id):
skipped_count += 1
continue
replace_jobs.append((member_account_id, resolved_role_id))
if replace_jobs:
if workers == 1:
for member_account_id, resolved_role_id in replace_jobs:
_replace_member_role(workspace_id, owner_account_id, member_account_id, resolved_role_id)
migrated_count += 1
else:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(
_replace_member_role,
workspace_id,
owner_account_id,
member_account_id,
resolved_role_id,
)
for member_account_id, resolved_role_id in replace_jobs
]
for future in as_completed(futures):
future.result()
migrated_count += 1
if scanned_count % 10000 == 0:
click.echo(
f"progress scanned={scanned_count} migrated={migrated_count} skipped={skipped_count}",
err=True,
)
if scanned_count == 0:
click.echo(click.style("No workspace members found for migration.", fg="yellow"))
return
owner_account_by_tenant: dict[str, str] = {}
resolved_role_ids: dict[tuple[str, str], str] = {}
migrated_count = 0
for join in joins:
workspace_id = str(join.tenant_id)
member_account_id = str(join.account_id)
legacy_role = str(join.role)
if workspace_id not in owner_account_by_tenant:
owner_join = next(
(
item
for item in joins
if str(item.tenant_id) == workspace_id and str(item.role) == TenantAccountRole.OWNER.value
),
None,
)
if not owner_join:
raise ValueError(f"Workspace owner not found for tenant={workspace_id}")
owner_account_by_tenant[workspace_id] = str(owner_join.account_id)
operator_account_id = owner_account_by_tenant[workspace_id]
cache_key = (workspace_id, legacy_role)
if cache_key not in resolved_role_ids:
resolved_role_ids[cache_key] = _resolve_builtin_role_id(workspace_id, operator_account_id, legacy_role)
resolved_role_id = resolved_role_ids[cache_key]
if dry_run:
click.echo(
f"tenant={workspace_id} member={member_account_id} "
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
click.style(
f"Dry run completed. Scanned {scanned_count} members across {tenant_count} tenants. "
"No RBAC bindings were written.",
fg="yellow",
)
)
else:
click.echo(
click.style(
f"RBAC member-role migration completed. Scanned {scanned_count} members across {tenant_count} tenants, "
f"migrated {migrated_count}, skipped {skipped_count} already up-to-date.",
fg="green",
)
)
if dry_run:
continue
RBACService.MemberRoles.replace(
tenant_id=workspace_id,
account_id=operator_account_id,
member_account_id=member_account_id,
role_ids=[resolved_role_id],
)
migrated_count += 1
def _dataset_permission_enum(permission: DatasetPermissionEnum | str | None) -> DatasetPermissionEnum:
if permission is None:
return DatasetPermissionEnum.ONLY_ME
try:
return DatasetPermissionEnum(permission)
except ValueError as exc:
raise ValueError(f"Unsupported legacy dataset permission: {permission}") from exc
def _rbac_dataset_scope_for_legacy_permission(permission: DatasetPermissionEnum) -> RBACResourceWhitelistScope:
if permission is DatasetPermissionEnum.ALL_TEAM:
return RBACResourceWhitelistScope.ALL
if permission in {DatasetPermissionEnum.ONLY_ME, DatasetPermissionEnum.PARTIAL_TEAM}:
return RBACResourceWhitelistScope.SPECIFIC
raise ValueError(f"Unsupported legacy dataset permission: {permission}")
def _emit_dataset_permission_migration_event(payload: dict[str, object]) -> None:
click.echo(json.dumps(payload, sort_keys=True))
@click.command(
"rbac-migrate-dataset-permissions",
help=(
"Migrate legacy dataset permission scopes and partial members into RBAC dataset access bindings. "
"Side effect: replacing each dataset whitelist clears existing per-user policy bindings; "
"the command then recreates legacy partial-member default bindings."
),
)
@click.option("--tenant-id", help="Only migrate datasets in a single workspace.")
@click.option("--dataset-id", help="Only migrate a single dataset.")
@click.option("--batch-size", default=500, show_default=True, type=click.IntRange(min=1))
@click.option(
"--dry-run/--apply",
default=True,
show_default=True,
help="Preview the migration without writing RBAC bindings. Use --apply to write changes.",
)
def migrate_dataset_permissions_to_rbac(
tenant_id: str | None,
dataset_id: str | None,
batch_size: int,
dry_run: bool,
) -> None:
"""Backfill RBAC dataset access config from legacy `Dataset.permission`.
Legacy mapping:
- all_team_members -> RBAC dataset whitelist scope "all"
- partial_members -> RBAC dataset whitelist scope "specific" plus each partial member gets the
virtual default policy
- only_me -> RBAC dataset whitelist scope "specific" with no member policy bindings
The command replaces each dataset's RBAC whitelist scope first. RBAC clears
existing per-user policy bindings during that replace, then this command
recreates the legacy partial-member default bindings. Re-running it is
therefore idempotent for a dataset's current legacy configuration.
"""
click.echo(click.style("Starting RBAC dataset permission migration.", fg="green"))
scanned_count = 0
scope_migrated_count = 0
user_policy_migrated_count = 0
partial_dataset_count = 0
last_dataset_id: str | None = None
while True:
with session_factory.create_session() as session:
stmt = (
select(Dataset.id, Dataset.tenant_id, Dataset.permission, Dataset.created_by)
.order_by(Dataset.id.asc())
.limit(batch_size)
)
if tenant_id:
stmt = stmt.where(Dataset.tenant_id == tenant_id)
if dataset_id:
stmt = stmt.where(Dataset.id == dataset_id)
if last_dataset_id:
stmt = stmt.where(Dataset.id > last_dataset_id)
dataset_rows = list(session.execute(stmt).all())
if not dataset_rows:
break
dataset_ids = [str(row.id) for row in dataset_rows]
partial_members_by_dataset_id: dict[str, list[str]] = {item: [] for item in dataset_ids}
permission_rows = session.execute(
select(DatasetPermission.dataset_id, DatasetPermission.account_id).where(
DatasetPermission.dataset_id.in_(dataset_ids)
)
).all()
for row in permission_rows:
partial_members_by_dataset_id[str(row.dataset_id)].append(str(row.account_id))
for dataset in dataset_rows:
workspace_id = str(dataset.tenant_id)
current_dataset_id = str(dataset.id)
operator_account_id = str(dataset.created_by)
permission_value = _dataset_permission_enum(dataset.permission)
scope = _rbac_dataset_scope_for_legacy_permission(permission_value)
partial_member_ids = sorted(set(partial_members_by_dataset_id[current_dataset_id]))
should_bind_partial_members = permission_value is DatasetPermissionEnum.PARTIAL_TEAM
click.echo(
f"tenant={workspace_id} dataset={current_dataset_id} "
f"operator={operator_account_id} "
f"legacy_permission={permission_value} -> rbac_scope={scope} "
f"partial_members={len(partial_member_ids) if should_bind_partial_members else 0}"
)
scanned_count += 1
replace_whitelist_payload = ReplaceMemberBindings(scope=scope)
if dry_run:
_emit_dataset_permission_migration_event(
{
"event": "dataset_permission_migration_proposed_change",
"action": "replace_whitelist",
"dry_run": True,
"tenant_id": workspace_id,
"dataset_id": current_dataset_id,
"operator_account_id": operator_account_id,
"before": {
"legacy_dataset_permission": permission_value.value,
"legacy_partial_member_ids": partial_member_ids if should_bind_partial_members else [],
},
"after": {
"rbac_whitelist_scope": scope.value,
},
"call": {
"method": "RBACService.DatasetAccess.replace_whitelist",
"kwargs": {
"tenant_id": workspace_id,
"account_id": operator_account_id,
"dataset_id": current_dataset_id,
"payload": replace_whitelist_payload.model_dump(mode="json"),
},
},
}
)
if not dry_run:
RBACService.DatasetAccess.replace_whitelist(
tenant_id=workspace_id,
account_id=operator_account_id,
dataset_id=current_dataset_id,
payload=replace_whitelist_payload,
)
scope_migrated_count += 1
if should_bind_partial_members:
partial_dataset_count += 1
for member_account_id in partial_member_ids:
replace_user_access_policies_payload = ReplaceUserAccessPolicies(
access_policy_ids=[_RBAC_DEFAULT_ACCESS_POLICY_ID],
)
if dry_run:
_emit_dataset_permission_migration_event(
{
"event": "dataset_permission_migration_proposed_change",
"action": "replace_user_access_policies",
"dry_run": True,
"tenant_id": workspace_id,
"dataset_id": current_dataset_id,
"operator_account_id": operator_account_id,
"target_account_id": member_account_id,
"before": {
"legacy_dataset_permission": permission_value.value,
"legacy_partial_member_id": member_account_id,
},
"after": {
"rbac_user_access_policy_ids": [_RBAC_DEFAULT_ACCESS_POLICY_ID],
},
"call": {
"method": "RBACService.DatasetAccess.replace_user_access_policies",
"kwargs": {
"tenant_id": workspace_id,
"account_id": operator_account_id,
"dataset_id": current_dataset_id,
"target_account_id": member_account_id,
"payload": replace_user_access_policies_payload.model_dump(mode="json"),
},
},
}
)
continue
RBACService.DatasetAccess.replace_user_access_policies(
tenant_id=workspace_id,
account_id=operator_account_id,
dataset_id=current_dataset_id,
target_account_id=member_account_id,
payload=replace_user_access_policies_payload,
)
user_policy_migrated_count += 1
last_dataset_id = dataset_ids[-1]
if dataset_id:
break
if scanned_count == 0:
click.echo(click.style("No datasets found for migration.", fg="yellow"))
return
if dry_run:
click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow"))
click.echo(
click.style(
f"Dry run completed. Scanned {scanned_count} datasets; "
f"{partial_dataset_count} partial-member datasets would be migrated.",
fg="yellow",
)
)
else:
click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green"))
click.echo(
click.style(
"RBAC dataset permission migration completed. "
f"Scanned {scanned_count} datasets, migrated {scope_migrated_count} scopes, "
f"wrote {user_policy_migrated_count} user default-policy bindings.",
fg="green",
)
)
+2 -29
View File
@@ -35,12 +35,6 @@ 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 []
@@ -162,16 +156,11 @@ 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(datetime.UTC)
now = datetime.datetime.now()
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.")
@@ -413,13 +402,6 @@ 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(
@@ -469,7 +451,7 @@ def archive_workflow_runs_plan(
default=None,
help="Archive runs created before this timestamp (UTC if no timezone).",
)
@click.option("--batch-size", default=10000, show_default=True, help="Maximum workflow runs per archive bundle.")
@click.option("--batch-size", default=100, show_default=True, help="Maximum workflow runs per archive bundle.")
@click.option(
"--workers",
default=1,
@@ -539,7 +521,6 @@ 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,
@@ -565,14 +546,6 @@ 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(
+6
View File
@@ -34,6 +34,12 @@ class EnterpriseFeatureConfig(BaseSettings):
default=False,
)
ENTERPRISE_RBAC_REQUEST_TIMEOUT: int = Field(
ge=1,
description="Maximum timeout in seconds for inner RBAC requests.",
default=30,
)
class EnterpriseTelemetryConfig(BaseSettings):
"""
+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. Set this to "
"false only when temporarily rolling back the drive integration."
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
"until the agent backend registers the dify.drive layer type."
),
default=True,
default=False,
)
-1
View File
@@ -183,7 +183,6 @@ 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
+1 -11
View File
@@ -6,15 +6,5 @@ from services.agent.roster_service import AgentRosterService
def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App:
"""Resolve a roster Agent's public Agent App."""
"""Resolve the hidden Agent App backing an Agent Console resource."""
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))
+16 -20
View File
@@ -1,10 +1,10 @@
from uuid import UUID
from flask import request
from flask_restx import Resource
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
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.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -28,15 +28,9 @@ 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,
WorkflowAgentComposerQuery,
WorkflowComposerCopyFromRosterPayload,
)
from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload
register_schema_models(
console_ns, ComposerSavePayload, WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload
)
register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload)
register_response_schema_models(
console_ns,
AgentAppComposerResponse,
@@ -47,26 +41,27 @@ 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,
),
)
@@ -142,7 +137,6 @@ 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,
@@ -234,9 +228,10 @@ 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_composer(tenant_id=tenant_id, agent_id=str(agent_id)),
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id),
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@@ -249,12 +244,13 @@ 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_composer(
AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
agent_id=str(agent_id),
app_id=app_id,
account_id=account_id,
payload=payload,
),
@@ -272,10 +268,9 @@ class AgentComposerValidateApi(Resource):
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id))
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=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,
@@ -295,11 +290,12 @@ 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,
agent_id=str(agent_id),
app_id=app_id,
user_id=current_user_id,
),
)
+11 -174
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, resolve_agent_runtime_app_model
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
from controllers.console.app.app import (
AppDetailWithSite as GenericAppDetailWithSite,
@@ -54,10 +54,8 @@ 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,
@@ -67,7 +65,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 ComposerSavePayload, RosterListQuery
from services.entities.agent_entities import RosterListQuery
from services.feature_service import FeatureService
@@ -234,8 +232,6 @@ 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
@@ -245,8 +241,6 @@ 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
@@ -256,36 +250,6 @@ 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")
@@ -297,9 +261,6 @@ register_schema_models(
AgentAppCreatePayload,
AgentAppUpdatePayload,
AgentAppCopyPayload,
AgentPublishPayload,
AgentBuildDraftCheckoutPayload,
ComposerSavePayload,
AgentApiStatusPayload,
AgentInviteOptionsQuery,
AgentLogsQuery,
@@ -316,10 +277,6 @@ register_response_schema_models(
AgentAppDetailWithSite,
AgentAppPartial,
AgentDebugConversationRefreshResponse,
AgentPublishResponse,
AgentBuildDraftResponse,
AgentBuildDraftApplyResponse,
AgentSimpleResultResponse,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentConfigSnapshotRestoreResponse,
@@ -337,7 +294,7 @@ def _agent_roster_service() -> AgentRosterService:
return AgentRosterService(db.session)
def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: str | None = None) -> dict:
def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
"""Serialize an Agent App detail using roster-only DTOs.
`/agent` responses are roster-shaped rather than raw app-shaped: `id`
@@ -354,23 +311,11 @@ def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: s
roster_service = _agent_roster_service()
payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
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))
)
agent = 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"] = 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["app_id"] = str(app_model.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,
@@ -420,8 +365,6 @@ 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 ""
@@ -573,8 +516,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_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))
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)
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@@ -640,112 +583,6 @@ 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__])
@@ -875,7 +712,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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = _resolve_agent_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")
@@ -912,7 +749,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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = _resolve_agent_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")
@@ -949,7 +786,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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = _resolve_agent_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)
@@ -968,7 +805,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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = _resolve_agent_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_runtime_app_model
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,
@@ -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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_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_runtime_app_model
from controllers.console.agent.app_helpers import resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_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_runtime_app_model
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 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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_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_runtime_app_model
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 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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_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_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_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:
+3 -4
View File
@@ -331,7 +331,7 @@ class ModelConfig(ResponseModel):
return to_timestamp(value)
class AppDetailSiteResponse(ResponseModel):
class Site(ResponseModel):
access_token: str | None = Field(default=None, validation_alias="code")
code: str | None = None
title: str | None = None
@@ -345,7 +345,6 @@ class AppDetailSiteResponse(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
@@ -462,7 +461,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: AppDetailSiteResponse | None = None
site: Site | 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.
@@ -547,7 +546,7 @@ register_schema_models(
WorkflowPartial,
ModelConfigPartial,
ModelConfig,
AppDetailSiteResponse,
Site,
DeletedTool,
AppDetail,
AppExportResponse,
+3 -7
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_runtime_app_model
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.error import (
AppUnavailableError,
CompletionRequestError,
@@ -93,10 +93,6 @@ 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
@@ -222,7 +218,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_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_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,
@@ -258,7 +254,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_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_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)
+6 -10
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_runtime_app_model
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.error import (
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
@@ -167,16 +167,12 @@ register_schema_models(
ChatMessagesQuery,
MessageFeedbackPayload,
FeedbackExportQuery,
)
register_response_schema_models(
console_ns,
AnnotationCountResponse,
SuggestedQuestionsResponse,
MessageDetailResponse,
MessageInfiniteScrollPaginationResponse,
SimpleResultResponse,
TextFileResponse,
)
register_response_schema_models(console_ns, SimpleResultResponse, TextFileResponse)
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
@@ -214,7 +210,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_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_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 +246,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_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_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 +311,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_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_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 +389,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_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_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,7 +40,6 @@ 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)
@@ -67,7 +66,6 @@ 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
@@ -112,7 +110,6 @@ class AppSite(Resource):
"customize_domain",
"copyright",
"privacy_policy",
"input_placeholder",
"custom_disclaimer",
"customize_token_strategy",
"prompt_public",
+2 -3
View File
@@ -16,7 +16,6 @@ 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
@@ -51,7 +50,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(db.session, current_user)
BillingService.is_tenant_owner_or_admin(current_user)
return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id)
@@ -65,7 +64,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(db.session, current_user)
BillingService.is_tenant_owner_or_admin(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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
document_ids = request.args.getlist("document_id")
@@ -1440,7 +1440,7 @@ class DocumentGenerateSummaryApi(Resource):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
metadata_args = MetadataOperationData.model_validate(console_ns.payload or {})
@@ -85,7 +85,6 @@ def _published_app_filter():
class InstalledAppInfoResponse(ResponseModel):
id: str
name: str | None = None
description: str | None = None
mode: str | None = None
icon_type: str | None = None
icon: str | None = None
@@ -124,7 +123,6 @@ class InstalledAppResponse(ResponseModel):
return {
"id": _safe_primitive(getattr(value, "id", "")) or "",
"name": _safe_primitive(getattr(value, "name", None)),
"description": _safe_primitive(getattr(value, "description", None)),
"mode": _safe_primitive(getattr(value, "mode", None)),
"icon_type": _safe_primitive(getattr(value, "icon_type", None)),
"icon": _safe_primitive(getattr(value, "icon", None)),
@@ -18,7 +18,6 @@ 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
@@ -353,7 +352,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(db.session, current_user)
BillingService.is_tenant_owner_or_admin(current_user)
data = BillingService.get_model_provider_payment_link(
provider_name=provider,
tenant_id=current_tenant_id,
+2 -8
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
from enum import StrEnum
from typing import Any
from flask import request
@@ -14,6 +13,7 @@ from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.wraps import RBACPermission, RBACResourceScope, rbac_permission_required
from core.db.session_factory import session_factory
from core.rbac import RBACResourceWhitelistScope
from libs.login import current_account_with_tenant, login_required
from models import Account
from services.enterprise import rbac_service as svc
@@ -511,14 +511,8 @@ class RBACAccessPolicyBindingUnlockApi(Resource):
# ---------------------------------------------------------------------------
class _AccessScope(StrEnum):
ALL = "all"
SPECIFIC = "specific"
ONLY_ME = "only_me"
class _ResourceAccessScopeRequest(BaseModel):
scope: _AccessScope
scope: RBACResourceWhitelistScope
class _ReplaceBindingsRequest(BaseModel):
+1 -2
View File
@@ -14,12 +14,11 @@ api = ExternalApi(
files_ns = Namespace("files", description="File operations", path="/")
from . import agent_drive_archive, image_preview, tool_files, upload
from . import image_preview, tool_files, upload
api.add_namespace(files_ns)
__all__ = [
"agent_drive_archive",
"api",
"bp",
"files_ns",
@@ -1,67 +0,0 @@
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
+1 -2
View File
@@ -20,7 +20,7 @@ openapi_ns = Namespace("openapi", description="User-scoped operations", path="/"
# Register response/query models BEFORE importing controller modules so that
# @openapi_ns.response / @openapi_ns.expect decorators can resolve model names.
from controllers.common.fields import EventStreamResponse, SimpleResultResponse
from controllers.common.fields import EventStreamResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.openapi._models import (
AccountPayload,
@@ -95,7 +95,6 @@ register_response_schema_models(
openapi_ns,
ErrorBody,
EventStreamResponse,
SimpleResultResponse,
UsageInfo,
MessageMetadata,
AppListRow,
+2 -3
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from collections.abc import Callable, Generator
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any
@@ -61,7 +61,7 @@ logger = logging.getLogger(__name__)
@contextmanager
def _translate_service_errors() -> Generator[None, None, None]:
def _translate_service_errors() -> Iterator[None]:
try:
yield
except WorkflowNotFoundError as ex:
@@ -166,7 +166,6 @@ class AppRunApi(Resource):
surface="apps",
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(stream_obj)
+3 -6
View File
@@ -25,13 +25,12 @@ from controllers.openapi._models import (
AppListRow,
)
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
from controllers.openapi.auth.data import AuthData, RBACRequirement
from controllers.service_api.app.error import AppUnavailableError
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import App
from models.enums import AppStatus
from models.model import AppMode
from services.account_service import TenantService
from services.app_service import AppListParams, AppService
@@ -167,9 +166,7 @@ class AppListApi(Resource):
# an empty set or list means the caller has no accessible apps.
# End-users bypass RBAC here — their access is controlled by scope upstream.
apply_rbac_filter = (
dify_config.RBAC_ENABLED
and auth_data.caller_kind != CallerKind.END_USER
and auth_data.account_id is not None
dify_config.RBAC_ENABLED and auth_data.caller_kind != "end_user" and auth_data.account_id is not None
)
access_filter = AppAccessFilter.unrestricted()
if apply_rbac_filter:
@@ -206,7 +203,7 @@ class AppListApi(Resource):
limit=query.limit,
mode=query.mode.value if query.mode else "all", # type:ignore
name=query.name,
status=AppStatus.NORMAL,
status="normal",
# Visibility gate pushed into the query — pagination.total stays
# consistent across pages because invisible rows never count.
openapi_visible=True,
@@ -25,7 +25,6 @@ from controllers.openapi.auth.data import AuthData, Edition
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import App
from models.enums import AppStatus
from services.account_service import TenantService
from services.app_service import AppService
from services.enterprise.app_permitted_service import list_permitted_apps
@@ -63,7 +62,7 @@ class PermittedExternalAppsListApi(Resource):
items: list[AppListRow] = []
for app_id in page_result.app_ids:
app = apps_by_id.get(app_id)
if not app or app.status != AppStatus.NORMAL:
if not app or app.status != "normal":
continue
tenant = tenants_by_id.get(str(app.tenant_id))
items.append(
+3 -7
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from werkzeug.exceptions import InternalServerError
@@ -20,11 +21,6 @@ class Edition(StrEnum):
SAAS = "saas"
class CallerKind(StrEnum):
ACCOUNT = "account"
END_USER = "end_user"
def current_edition() -> Edition:
if dify_config.EDITION == "CLOUD":
return Edition.SAAS
@@ -82,9 +78,9 @@ class AuthData(BaseModel):
tenant_role: TenantAccountRole | None = None
caller: Account | EndUser | None = None
caller_kind: CallerKind | None = None
caller_kind: Literal["account", "end_user"] | None = None
def require_app_context(self) -> tuple[App, Account | EndUser, CallerKind]:
def require_app_context(self) -> tuple[App, Account | EndUser, Literal["account", "end_user"]]:
if self.app is None or self.caller is None or self.caller_kind is None:
raise InternalServerError("pipeline_invariant_violated: app context missing")
return self.app, self.caller, self.caller_kind
+7 -7
View File
@@ -5,10 +5,10 @@ import uuid
from flask import request
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
from controllers.openapi.auth.data import AuthData, CallerKind
from controllers.openapi.auth.data import AuthData
from extensions.ext_database import db
from models.account import AccountStatus, TenantStatus
from models.enums import AppStatus, EndUserType
from models.account import TenantStatus
from models.enums import EndUserType
from services.account_service import AccountService, TenantService
from services.app_service import AppService
from services.end_user_service import EndUserService
@@ -24,7 +24,7 @@ def load_app(data: AuthData) -> None:
except ValueError:
raise NotFound("app not found")
app = AppService.get_app_by_id(db.session, app_id)
if not app or app.status != AppStatus.NORMAL:
if not app or app.status != "normal":
raise NotFound("app not found")
data.app = app
@@ -65,7 +65,7 @@ def load_account(data: AuthData) -> None:
if data.tenant:
account.current_tenant = data.tenant
data.caller = account
data.caller_kind = CallerKind.ACCOUNT
data.caller_kind = "account"
def load_workspace_role(data: AuthData) -> None:
@@ -73,7 +73,7 @@ def load_workspace_role(data: AuthData) -> None:
return
if data.tenant is None or data.account_id is None:
return
if data.caller is not None and getattr(data.caller, "status", None) != AccountStatus.ACTIVE:
if data.caller is not None and getattr(data.caller, "status", None) != "active":
return
role = TenantService.get_account_role_in_tenant(db.session, str(data.account_id), str(data.tenant.id))
if role is None:
@@ -91,7 +91,7 @@ def resolve_external_user(data: AuthData) -> None:
user_id=data.external_identity.email,
)
data.caller = end_user
data.caller_kind = CallerKind.END_USER
data.caller_kind = "end_user"
def load_app_access_mode(data: AuthData) -> None:
+2 -2
View File
@@ -5,7 +5,7 @@ from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity
from configs import dify_config
from controllers.common.wraps import enforce_rbac_access
from controllers.openapi.auth.data import AuthData, CallerKind
from controllers.openapi.auth.data import AuthData
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from services.account_service import AccountService, TenantService
@@ -58,7 +58,7 @@ def check_rbac_permission(data: AuthData) -> None:
if not dify_config.RBAC_ENABLED:
return
# Only account callers are subject to RBAC; end_user access is scope-controlled.
if data.caller_kind != CallerKind.ACCOUNT:
if data.caller_kind != "account":
return
if data.account_id is None or data.tenant is None:
raise Forbidden("rbac context missing")
+2 -2
View File
@@ -22,7 +22,7 @@ from controllers.openapi._contract import accepts, returns
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
from controllers.openapi.auth.data import AuthData, RBACRequirement
from core.workflow.human_input_policy import (
HumanInputSurface,
is_recipient_type_allowed_for_surface,
@@ -98,7 +98,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
submission_user_id: str | None = None
submission_end_user_id: str | None = None
if caller_kind == CallerKind.ACCOUNT:
if caller_kind == "account":
submission_user_id = caller.id
else:
submission_end_user_id = caller.id
+2 -2
View File
@@ -22,7 +22,7 @@ from controllers.common.schema import query_params_from_model
from controllers.common.wraps import RBACPermission, RBACResourceScope
from controllers.openapi import openapi_ns
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
from controllers.openapi.auth.data import AuthData, RBACRequirement
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
@@ -70,7 +70,7 @@ class OpenApiWorkflowEventsApi(Resource):
if workflow_run.app_id != app_model.id:
raise NotFound("Workflow run not found")
if caller_kind == CallerKind.ACCOUNT:
if caller_kind == "account":
if workflow_run.created_by_role != CreatorUserRole.ACCOUNT or workflow_run.created_by != caller.id:
raise NotFound("Workflow run not found")
else:
@@ -565,7 +565,7 @@ class DatasetApi(DatasetApiResource):
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
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, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
metadata_args = MetadataOperationData.model_validate(service_api_ns.payload or {})
+5 -29
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 FeatureModel, FeatureService
from services.feature_service import FeatureService
class AppSiteModelConfigResponse(ResponseModel):
@@ -38,7 +38,6 @@ 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
@@ -85,7 +84,6 @@ 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,
@@ -129,15 +127,9 @@ class AppSiteApi(WebApiResource):
if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
return AppSiteInfo(
app_model.tenant,
app_model,
serialize_runtime_site(site, features),
end_user.id,
features.can_replace_logo,
)
return AppSiteInfo(app_model.tenant, app_model, site, end_user.id, can_replace_logo)
class AppSiteInfo:
@@ -172,23 +164,7 @@ 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]:
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,
)
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)
return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields))
+27 -130
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 and_, or_, select
from sqlalchemy import select
from clients.agent_backend import AgentBackendRunEventAdapter
from clients.agent_backend.factory import create_agent_backend_run_client
@@ -42,15 +42,7 @@ 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,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigSnapshot,
AgentScope,
AgentSource,
AgentStatus,
)
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from services.conversation_service import ConversationService
@@ -81,15 +73,10 @@ class AgentAppGenerator(MessageBasedAppGenerator):
inputs = args["inputs"]
# Resolve the bound roster Agent + its current Agent Soul snapshot.
agent, agent_config_id, agent_soul = self._resolve_agent(
app_model,
invoke_from=invoke_from,
draft_type=args.get("draft_type"),
user=user,
)
agent, snapshot, agent_soul = self._resolve_agent(app_model)
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
invoke_from=invoke_from,
snapshot_id=agent_config_id,
snapshot_id=snapshot.id,
)
conversation = None
@@ -136,7 +123,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
call_depth=0,
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=agent_config_id,
agent_config_snapshot_id=snapshot.id,
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
)
@@ -192,12 +179,7 @@ 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, agent_config_id, agent_soul = self._resolve_agent(
app_model,
invoke_from=invoke_from,
draft_type="draft",
user=user,
)
agent, snapshot, agent_soul = self._resolve_agent(app_model)
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=user
)
@@ -244,7 +226,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
call_depth=0,
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=agent_config_id,
agent_config_snapshot_id=snapshot.id,
)
conversation, message = self._init_generate_records(application_generate_entity, conversation)
@@ -439,135 +421,50 @@ class AgentAppGenerator(MessageBasedAppGenerator):
return False, query
def _resolve_agent(
self,
app_model: App,
*,
invoke_from: InvokeFrom,
draft_type: Any,
user: Account | EndUser,
) -> tuple[Agent, str, AgentSoulConfig]:
def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
select(Agent).where(
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
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")
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 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 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.
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.
"""
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 | AgentConfigDraft, AgentSoulConfig]:
) -> tuple[Agent, AgentConfigSnapshot, 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.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:
snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id))
if snapshot is None:
raise AgentAppGeneratorError("Agent published version not found")
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
return agent, draft, agent_soul
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return agent, snapshot, agent_soul
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
+15 -114
View File
@@ -22,10 +22,7 @@ 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 (
@@ -476,39 +473,6 @@ 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.
@@ -525,7 +489,6 @@ 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:
@@ -555,9 +518,7 @@ class ProviderConfiguration(BaseModel):
)
provider_model_credentials_cache.delete()
preferred_model_providers_changed = self.switch_preferred_provider_type(
provider_type=ProviderType.CUSTOM, session=session
)
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
else:
provider_record.is_valid = True
@@ -572,18 +533,12 @@ class ProviderConfiguration(BaseModel):
)
provider_model_credentials_cache.delete()
preferred_model_providers_changed = self.switch_preferred_provider_type(
provider_type=ProviderType.CUSTOM, session=session
)
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,
@@ -607,7 +562,6 @@ 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(
@@ -634,7 +588,7 @@ class ProviderConfiguration(BaseModel):
)
provider_model_credentials_cache.delete()
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
self._update_load_balancing_configs_with_credential(
credential_id=credential_id,
credential_record=credential_record,
credential_source=CredentialSourceType.PROVIDER,
@@ -643,10 +597,6 @@ 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,
@@ -654,7 +604,7 @@ class ProviderConfiguration(BaseModel):
credential_record: ProviderCredential | ProviderModelCredential,
credential_source: str,
session: Session,
) -> bool:
):
"""
Update load balancing configurations that reference the given credential_id.
@@ -675,7 +625,7 @@ class ProviderConfiguration(BaseModel):
load_balancing_configs = session.execute(stmt).scalars().all()
if not load_balancing_configs:
return False
return
# Update each load balancing config with the new credentials
for lb_config in load_balancing_configs:
@@ -693,7 +643,6 @@ class ProviderConfiguration(BaseModel):
lb_credentials_cache.delete()
session.commit()
return True
def delete_provider_credential(self, credential_id: str):
"""
@@ -702,8 +651,6 @@ 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,
@@ -724,7 +671,6 @@ 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(
@@ -757,9 +703,7 @@ class ProviderConfiguration(BaseModel):
cache_type=ProviderCredentialsCacheType.PROVIDER,
)
provider_model_credentials_cache.delete()
preferred_model_providers_changed = self.switch_preferred_provider_type(
provider_type=ProviderType.SYSTEM, session=session
)
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()
@@ -770,19 +714,12 @@ class ProviderConfiguration(BaseModel):
cache_type=ProviderCredentialsCacheType.PROVIDER,
)
provider_model_credentials_cache.delete()
preferred_model_providers_changed = self.switch_preferred_provider_type(
provider_type=ProviderType.SYSTEM, session=session
)
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):
"""
@@ -791,7 +728,6 @@ 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,
@@ -817,14 +753,10 @@ class ProviderConfiguration(BaseModel):
cache_type=ProviderCredentialsCacheType.PROVIDER,
)
provider_model_credentials_cache.delete()
preferred_model_providers_changed = self.switch_preferred_provider_type(
ProviderType.CUSTOM, session=session
)
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,
@@ -1085,10 +1017,6 @@ 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,
@@ -1125,7 +1053,6 @@ 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)
@@ -1155,7 +1082,7 @@ class ProviderConfiguration(BaseModel):
)
provider_model_credentials_cache.delete()
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
self._update_load_balancing_configs_with_credential(
credential_id=credential_id,
credential_record=credential_record,
credential_source=CredentialSourceType.CUSTOM_MODEL,
@@ -1164,11 +1091,6 @@ 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):
"""
@@ -1177,7 +1099,6 @@ 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,
@@ -1197,7 +1118,6 @@ 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:
@@ -1241,11 +1161,6 @@ 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):
"""
@@ -1298,7 +1213,6 @@ 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):
"""
@@ -1337,7 +1251,6 @@ 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):
"""
@@ -1346,7 +1259,6 @@ 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)
@@ -1355,7 +1267,6 @@ 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,
@@ -1364,8 +1275,6 @@ 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
@@ -1405,7 +1314,6 @@ class ProviderConfiguration(BaseModel):
)
session.add(model_setting)
session.commit()
self._invalidate_provider_configuration_cache(provider_model_settings=True)
return model_setting
@@ -1432,7 +1340,6 @@ class ProviderConfiguration(BaseModel):
)
session.add(model_setting)
session.commit()
self._invalidate_provider_configuration_cache(provider_model_settings=True)
return model_setting
@@ -1485,7 +1392,6 @@ class ProviderConfiguration(BaseModel):
)
session.add(model_setting)
session.commit()
self._invalidate_provider_configuration_cache(provider_model_settings=True)
return model_setting
@@ -1513,7 +1419,6 @@ class ProviderConfiguration(BaseModel):
)
session.add(model_setting)
session.commit()
self._invalidate_provider_configuration_cache(provider_model_settings=True)
return model_setting
@@ -1549,19 +1454,19 @@ class ProviderConfiguration(BaseModel):
credentials=credentials or {},
)
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None) -> bool:
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None):
"""
Switch preferred provider type.
:param provider_type:
:return:
"""
if provider_type == self.preferred_provider_type:
return False
return
if provider_type == ProviderType.SYSTEM and not self.system_configuration.enabled:
return False
return
def _switch(s: Session) -> bool:
def _switch(s: Session):
stmt = select(TenantPreferredModelProvider).where(
TenantPreferredModelProvider.tenant_id == self.tenant_id,
TenantPreferredModelProvider.provider_name.in_(self._get_provider_names()),
@@ -1578,16 +1483,12 @@ class ProviderConfiguration(BaseModel):
)
s.add(preferred_model_provider)
s.commit()
return True
if session:
return _switch(session)
else:
with Session(db.engine) as session:
changed = _switch(session)
if changed:
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
return changed
return _switch(session)
def extract_secret_variables(self, credential_form_schemas: list[CredentialFormSchema]) -> list[str]:
"""
+55 -620
View File
@@ -1,14 +1,10 @@
from __future__ import annotations
import contextlib
import json
import logging
from collections import defaultdict
from collections.abc import Callable, Sequence
from dataclasses import asdict, dataclass
from enum import StrEnum
from collections.abc import Sequence
from json import JSONDecodeError
from typing import TYPE_CHECKING, Any, Protocol, Self
from typing import TYPE_CHECKING, Any
from pydantic import TypeAdapter
from sqlalchemy import select
@@ -45,7 +41,6 @@ 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,
@@ -64,494 +59,7 @@ 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:
@@ -590,14 +98,6 @@ 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.
@@ -692,9 +192,6 @@ 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
@@ -727,12 +224,7 @@ class ProviderManager:
# Convert to custom configuration
custom_configuration = self._to_custom_configuration(
tenant_id,
provider_entity,
provider_records,
provider_model_records,
provider_model_credentials,
provider_name_to_provider_credentials_dict,
tenant_id, provider_entity, provider_records, provider_model_records, provider_model_credentials
)
# Convert to system configuration
@@ -956,115 +448,84 @@ 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 = list(session.scalars(stmt))
_attach_active_credentials(
session=session,
records=providers,
credential_model_cls=ProviderCredential,
)
providers = session.scalars(stmt)
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[_ProviderModelCacheEntry]]:
def _get_all_provider_models(tenant_id: str) -> dict[str, list[ProviderModel]]:
"""
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)
for provider_model in provider_models:
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
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)
return provider_name_to_provider_model_records_dict
@staticmethod
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, _TenantPreferredModelProviderCacheEntry]:
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, TenantPreferredModelProvider]:
"""
Get All preferred provider types of the workspace.
:param tenant_id: workspace id
:return:
"""
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
}
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
@staticmethod
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[_ProviderModelSettingCacheEntry]]:
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[ProviderModelSetting]]:
"""
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)
for provider_model_setting in provider_model_settings:
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
provider_model_setting
)
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
)
return provider_name_to_provider_model_settings_dict
@staticmethod
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[_ProviderModelCredentialCacheEntry]]:
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[ProviderModelCredential]]:
"""
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)
for provider_model_credential in provider_model_credentials:
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
provider_model_credential
)
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
)
return provider_name_to_provider_model_credentials_dict
@staticmethod
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]]:
def _get_all_provider_load_balancing_configs(tenant_id: str) -> dict[str, list[LoadBalancingModelConfig]]:
"""
Get All provider load balancing configs of the workspace.
@@ -1085,16 +546,14 @@ 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)
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)
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)
return provider_name_to_provider_load_balancing_model_configs_dict
@@ -1263,9 +722,8 @@ class ProviderManager:
tenant_id: str,
provider_entity: ProviderEntity,
provider_records: list[Provider],
provider_model_records: list[_ProviderModelCacheEntry],
provider_model_credentials: list[_ProviderModelCredentialCacheEntry],
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
provider_model_records: list[ProviderModel],
provider_model_credentials: list[ProviderModelCredential],
) -> CustomConfiguration:
"""
Convert to custom configuration.
@@ -1278,10 +736,7 @@ class ProviderManager:
"""
# Get custom provider configuration
custom_provider_configuration = self._get_custom_provider_configuration(
tenant_id,
provider_entity,
provider_records,
provider_credentials_by_name,
tenant_id, provider_entity, provider_records
)
# Get custom models which have not been added to the model list yet
@@ -1303,11 +758,7 @@ class ProviderManager:
)
def _get_custom_provider_configuration(
self,
tenant_id: str,
provider_entity: ProviderEntity,
provider_records: list[Provider],
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
self, tenant_id: str, provider_entity: ProviderEntity, provider_records: list[Provider]
) -> CustomProviderConfiguration | None:
"""Get custom provider configuration."""
# Find custom provider record (non-system)
@@ -1339,29 +790,13 @@ 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_from_records(
custom_provider_record.provider_name,
provider_credentials_by_name,
available_credentials=self.get_provider_available_credentials(
tenant_id, custom_provider_record.provider_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[_ProviderModelCacheEntry],
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
self, provider_model_records: list[ProviderModel], all_model_credentials: Sequence[ProviderModelCredential]
) -> 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}
@@ -1394,9 +829,9 @@ class ProviderManager:
self,
tenant_id: str,
provider_entity: ProviderEntity,
provider_model_records: list[_ProviderModelCacheEntry],
provider_model_records: list[ProviderModel],
can_added_models: list[dict],
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
all_model_credentials: Sequence[ProviderModelCredential],
) -> list[CustomModelConfiguration]:
"""Get custom model configurations."""
# Get model credential secret variables
@@ -1716,8 +1151,8 @@ class ProviderManager:
def _to_model_settings(
self,
provider_entity: ProviderEntity,
provider_model_settings: list[_ProviderModelSettingCacheEntry] | None = None,
load_balancing_model_configs: list[_LoadBalancingModelConfigCacheEntry] | None = None,
provider_model_settings: list[ProviderModelSetting] | None = None,
load_balancing_model_configs: list[LoadBalancingModelConfig] | None = None,
) -> list[ModelSettings]:
"""
Convert to model settings.
+2 -2
View File
@@ -1,3 +1,3 @@
from core.rbac.entities import RBACPermission, RBACResourceScope
from core.rbac.entities import RBACPermission, RBACResourceScope, RBACResourceWhitelistScope
__all__ = ["RBACPermission", "RBACResourceScope"]
__all__ = ["RBACPermission", "RBACResourceScope", "RBACResourceWhitelistScope"]
+8
View File
@@ -13,6 +13,14 @@ class RBACResourceScope(StrEnum):
WORKSPACE = "workspace"
class RBACResourceWhitelistScope(StrEnum):
"""Whitelist scopes accepted by RBAC app and dataset access config APIs."""
ALL = "all"
SPECIFIC = "specific"
ONLY_ME = "only_me"
class RBACPermission(StrEnum):
"""Permission points (RBAC scenes) checked by ``rbac_permission_required``.
+2 -18
View File
@@ -31,7 +31,6 @@ 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
@@ -689,20 +688,5 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
node_id: str,
node_data: DifyAgentNodeData,
) -> Mapping[str, Sequence[str]]:
"""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)
}
del graph_config, node_id, node_data
return {}
@@ -3,7 +3,6 @@ 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(
{
@@ -49,7 +48,9 @@ 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 agent_soul.knowledge.sets else "not_configured"
reserved_status["knowledge"] = (
"supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) 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"
@@ -65,14 +66,14 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
def list_configured_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
"""Return normalized dataset ids selected by Agent v2 knowledge sets.
"""Return the normalized knowledge dataset ids that can produce a runtime layer.
``build_runtime_feature_manifest()`` and ``build_knowledge_layer_config()``
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.
must stay aligned: both decide knowledge support from this effective,
non-blank dataset-id set rather than from raw
``agent_soul.knowledge.datasets`` entries.
"""
return list_agent_soul_knowledge_dataset_ids(agent_soul)
return [dataset_id for dataset in agent_soul.knowledge.datasets if (dataset_id := (dataset.id or "").strip())]
def _get_nested(value: dict[str, Any], path: str) -> Any:
@@ -1,12 +1,10 @@
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,
@@ -17,16 +15,7 @@ from dify_agent.layers.execution_context import (
DifyExecutionContextLayerConfig,
DifyExecutionContextUserFrom,
)
from dify_agent.layers.knowledge import (
DifyKnowledgeBaseLayerConfig,
DifyKnowledgeDatasetConfig,
DifyKnowledgeMetadataFilteringConfig,
DifyKnowledgeModelConfig,
DifyKnowledgeQueryConfig,
DifyKnowledgeRerankingModelConfig,
DifyKnowledgeRetrievalConfig,
DifyKnowledgeSetConfig,
)
from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig, DifyKnowledgeRetrievalConfig
from dify_agent.layers.shell import (
DifyShellCliToolConfig,
DifyShellEnvVarConfig,
@@ -35,7 +24,7 @@ from dify_agent.layers.shell import (
DifyShellSecretRefConfig,
)
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel
from clients.agent_backend import (
AgentBackendModelConfig,
@@ -47,13 +36,11 @@ 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 File, FileTransferMethod
from graphon.file import FileTransferMethod
from graphon.variables.segments import Segment
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentKnowledgeMetadataFilteringConfig,
AgentKnowledgeModelConfig,
AgentKnowledgeRetrievalConfig,
AgentKnowledgeQueryConfig,
AgentSoulConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
@@ -71,16 +58,13 @@ 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
from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation")
@@ -90,13 +74,6 @@ _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):
@@ -149,9 +126,6 @@ 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,
*,
@@ -173,13 +147,15 @@ class WorkflowAgentRuntimeRequestBuilder:
)
metadata = self._build_metadata(context, agent_soul, node_job)
effective_node_job = node_job.model_copy(
update={"previous_node_output_refs": self._effective_previous_node_output_refs(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."
)
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
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
tools_layer = self._plugin_tools_builder.build(
@@ -334,19 +310,15 @@ class WorkflowAgentRuntimeRequestBuilder:
context: WorkflowAgentRuntimeBuildContext,
node_job: WorkflowNodeJobConfig,
) -> str:
lines: list[str] = []
lines = ["Workflow context loaded for this run:"]
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:
@@ -355,31 +327,6 @@ 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,
@@ -387,7 +334,7 @@ class WorkflowAgentRuntimeRequestBuilder:
) -> list[dict[str, Any]]:
resolved: list[dict[str, Any]] = []
for ref in refs:
selector = normalize_previous_node_output_selector(ref)
selector = self._selector_from_ref(ref)
if not selector:
raise WorkflowAgentRuntimeRequestBuildError(
"invalid_previous_node_output_ref",
@@ -409,72 +356,24 @@ class WorkflowAgentRuntimeRequestBuilder:
return resolved
@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=(",", ":"))
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:
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.
@@ -648,84 +547,42 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None:
"""Map Agent Soul knowledge sets into one Dify knowledge-base layer.
"""Map Agent Soul knowledge config into the fixed Dify knowledge-base layer.
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.
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).
"""
if not agent_soul.knowledge.sets:
dataset_ids = list_configured_knowledge_dataset_ids(agent_soul)
if not dataset_ids:
return None
query_config = agent_soul.knowledge.query_config
return DifyKnowledgeBaseLayerConfig(
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
],
dataset_ids=dataset_ids,
retrieval=DifyKnowledgeRetrievalConfig(
mode="multiple",
top_k=_knowledge_top_k(query_config),
score_threshold=_knowledge_score_threshold(query_config),
),
)
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_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_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 _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 build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None:
@@ -18,7 +18,6 @@ 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
@@ -147,7 +146,6 @@ 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)
@@ -366,24 +364,6 @@ 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,
+20 -96
View File
@@ -2,8 +2,8 @@
This checker intentionally stays conservative. It only reports a hard schema
mismatch when both sides are statically known for the same 2xx status code:
a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``,
``Model(...).model_dump()``, or ``Model.model_validate(...).model_dump()`` return.
a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``
or ``Model.model_validate(...).model_dump()`` return.
Raw dictionaries, raw lists, ``None`` responses, streaming helpers, missing
response schemas, and returns with non-literal status codes are classified as
@@ -28,7 +28,6 @@ from typing import Any, Literal
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put"}
NO_BODY_STATUSES = {HTTPStatus.NO_CONTENT.value, HTTPStatus.RESET_CONTENT.value, HTTPStatus.NOT_MODIFIED.value}
DEFAULT_CONTROLLER_DIRS = ("controllers/console", "controllers/service_api", "controllers/web")
IGNORE_COMMENT_MARKERS = ("response-contract:ignore",)
type Classification = Literal["valid", "mismatch", "unknown", "refactorable"]
type ActualKind = Literal[
@@ -42,7 +41,6 @@ type ActualKind = Literal[
"unknown",
]
type MethodNode = ast.FunctionDef | ast.AsyncFunctionDef
type ModelValueSource = Literal["constructor", "model_validate"]
HTTP_STATUS_NAMES = {status.name: status.value for status in HTTPStatus}
HTTP_STATUS_NAMES.update({f"HTTP_{status.value}_{status.name}": status.value for status in HTTPStatus})
@@ -111,22 +109,18 @@ class VariableAssignmentSummary:
"""Track whether a local name is safe to treat as one specific response model."""
known_models: set[str] = field(default_factory=set)
known_sources: set[ModelValueSource] = field(default_factory=set)
has_unknown_assignment: bool = False
def add_known(self, model: str, source: ModelValueSource) -> None:
def add_known(self, model: str) -> None:
self.known_models.add(model)
self.known_sources.add(source)
def add_unknown(self) -> None:
self.has_unknown_assignment = True
def single_known_model(self) -> tuple[str, ModelValueSource] | None:
def single_known_model(self) -> str | None:
if self.has_unknown_assignment or len(self.known_models) != 1:
return None
model = next(iter(self.known_models))
source: ModelValueSource = "constructor" if self.known_sources == {"constructor"} else "model_validate"
return model, source
return next(iter(self.known_models))
def dotted_name(node: ast.AST) -> str | None:
@@ -255,12 +249,6 @@ def model_name_from_model_validate_call(node: ast.AST) -> str | None:
return None
def model_value_from_model_validate_call(node: ast.AST) -> tuple[str, ModelValueSource] | None:
if model_name := model_name_from_model_validate_call(node):
return model_name, "model_validate"
return None
def model_name_from_constructor_call(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call):
return None
@@ -269,12 +257,6 @@ def model_name_from_constructor_call(node: ast.AST) -> str | None:
return None
def model_value_from_constructor_call(node: ast.AST) -> tuple[str, ModelValueSource] | None:
if model_name := model_name_from_constructor_call(node):
return model_name, "constructor"
return None
def model_name_from_model_dump(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute) or node.func.attr != "model_dump":
return None
@@ -290,10 +272,6 @@ def model_name_from_model_value(node: ast.AST) -> str | None:
return model_name_from_model_validate_call(node) or model_name_from_constructor_call(node)
def model_value_from_model_value(node: ast.AST) -> tuple[str, ModelValueSource] | None:
return model_value_from_model_validate_call(node) or model_value_from_constructor_call(node)
def model_name_from_dump_response(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call):
return None
@@ -309,7 +287,7 @@ def model_name_from_dump_response(node: ast.AST) -> str | None:
def actual_kind_from_expr(
expr: ast.AST | None, variable_models: dict[str, tuple[str, ModelValueSource]] | None = None
expr: ast.AST | None, variable_models: dict[str, str] | None = None
) -> tuple[ActualKind, str | None]:
if expr is None:
return "none", None
@@ -321,14 +299,10 @@ def actual_kind_from_expr(
if isinstance(expr, ast.Call) and isinstance(expr.func, ast.Attribute) and expr.func.attr == "model_dump":
dumped_value = expr.func.value
if isinstance(dumped_value, ast.Name) and variable_models:
model_assignment = variable_models.get(dumped_value.id)
if model_assignment:
model_name, source = model_assignment
if source == "constructor":
return "model", model_name
# A variable dump from model_validate can match today, but it
# bypasses dump_response and is easier to drift; keep it visible
# as refactorable.
# A variable dump can match today, but it bypasses dump_response and
# is easier to drift; keep it visible as refactorable.
model_name = variable_models.get(dumped_value.id)
if model_name:
return "model_dump_variable", model_name
model_dump_model = model_name_from_model_dump(expr)
@@ -351,9 +325,7 @@ def actual_kind_from_expr(
return "unknown", None
def actual_response_from_return(
return_node: ast.Return, variable_models: dict[str, tuple[str, ModelValueSource]]
) -> ActualResponse:
def actual_response_from_return(return_node: ast.Return, variable_models: dict[str, str]) -> ActualResponse:
status: int | None = 200
body_expr = return_node.value
@@ -391,21 +363,18 @@ def target_names(target: ast.AST) -> Iterable[str]:
def record_assignment(
assignments: defaultdict[str, VariableAssignmentSummary],
targets: Iterable[str],
model_assignment: tuple[str, ModelValueSource] | None,
assignments: defaultdict[str, VariableAssignmentSummary], targets: Iterable[str], model_name: str | None
) -> None:
for target in targets:
if model_assignment is None:
if model_name is None:
# Once a name receives an unknown value, later model_dump() calls on it
# are no longer a reliable signal for the returned schema.
assignments[target].add_unknown()
else:
model_name, source = model_assignment
assignments[target].add_known(model_name, source)
assignments[target].add_known(model_name)
def variable_model_assignments_for_method(method: MethodNode) -> dict[str, tuple[str, ModelValueSource]]:
def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
"""Infer local variables that are unambiguously assigned one response model."""
assignments: defaultdict[str, VariableAssignmentSummary] = defaultdict(VariableAssignmentSummary)
@@ -416,10 +385,10 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, tuple
record_assignment(
assignments,
(name for target in targets for name in target_names(target)),
model_value_from_model_value(value),
model_name_from_model_value(value),
)
case ast.AnnAssign(target=target, value=value) if value is not None:
record_assignment(assignments, target_names(target), model_value_from_model_value(value))
record_assignment(assignments, target_names(target), model_name_from_model_value(value))
case ast.AugAssign(target=target) | ast.For(target=target) | ast.AsyncFor(target=target):
# Mutation and loop targets overwrite prior values with runtime-dependent data.
record_assignment(assignments, target_names(target), None)
@@ -430,13 +399,9 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, tuple
case ast.ExceptHandler(name=name) if name:
assignments[name].add_unknown()
case ast.NamedExpr(target=target, value=value):
record_assignment(assignments, target_names(target), model_value_from_model_value(value))
record_assignment(assignments, target_names(target), model_name_from_model_value(value))
return {
name: assignment
for name, summary in assignments.items()
if (assignment := summary.single_known_model()) is not None
}
return {name: model for name, summary in assignments.items() if (model := summary.single_known_model()) is not None}
def actual_responses_for_method(method: MethodNode) -> list[ActualResponse]:
@@ -580,52 +545,13 @@ def iter_controller_files(paths: Iterable[Path]) -> Iterable[Path]:
yield from sorted(child for child in path.rglob("*.py") if child.is_file())
def node_start_lineno(node: ast.ClassDef | MethodNode) -> int:
decorator_lines = [decorator.lineno for decorator in node.decorator_list]
if decorator_lines:
return min(decorator_lines)
return node.lineno
def line_has_ignore_marker(line: str) -> bool:
_, marker, comment = line.partition("#")
if not marker:
return False
normalized = comment.lower()
return any(ignore_marker in normalized for ignore_marker in IGNORE_COMMENT_MARKERS)
def node_has_ignore_comment(lines: Sequence[str], node: ast.ClassDef | MethodNode) -> bool:
start = node_start_lineno(node)
end = node.end_lineno or node.lineno
if any(line_has_ignore_marker(line) for line in lines[start - 1 : end]):
return True
line_index = start - 2
while line_index >= 0:
stripped = lines[line_index].strip()
if not stripped:
line_index -= 1
continue
if not stripped.startswith("#"):
break
if line_has_ignore_marker(lines[line_index]):
return True
line_index -= 1
return False
def checks_for_file(file_path: Path, repo_root: Path) -> list[ContractCheck]:
source = file_path.read_text(encoding="utf-8")
lines = source.splitlines()
module = ast.parse(source, filename=str(file_path))
module = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
checks: list[ContractCheck] = []
for node in module.body:
if not isinstance(node, ast.ClassDef):
continue
if node_has_ignore_comment(lines, node):
continue
class_routes = routes_from_decorators(node.decorator_list)
class_documented = response_docs_from_decorators(node.decorator_list)
@@ -633,8 +559,6 @@ def checks_for_file(file_path: Path, repo_root: Path) -> list[ContractCheck]:
for item in node.body:
if not isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef) or item.name not in HTTP_METHODS:
continue
if node_has_ignore_comment(lines, item):
continue
routes = routes_from_decorators(item.decorator_list) or class_routes
if not routes:
+2
View File
@@ -27,6 +27,7 @@ def init_app(app: DifyApp):
install_plugins,
install_rag_pipeline_plugins,
migrate_data_for_plugin,
migrate_dataset_permissions_to_rbac,
migrate_member_roles_to_rbac,
migrate_oss,
migration_data_wizard,
@@ -56,6 +57,7 @@ def init_app(app: DifyApp):
upgrade_db,
fix_app_site_missing,
migrate_data_for_plugin,
migrate_dataset_permissions_to_rbac,
migrate_member_roles_to_rbac,
backfill_plugin_auto_upgrade,
extract_plugins,
+2 -47
View File
@@ -6,7 +6,6 @@ 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,
@@ -48,18 +47,6 @@ 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
@@ -85,8 +72,6 @@ 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
@@ -307,24 +292,14 @@ 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
@@ -368,9 +343,6 @@ 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
@@ -378,15 +350,10 @@ class WorkflowAgentComposerResponse(ResponseModel):
class AgentAppComposerResponse(ResponseModel):
variant: Literal[ComposerVariant.AGENT_APP]
agent: AgentComposerAgentResponse
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
draft: AgentConfigDraftSummaryResponse | None = None
active_config_snapshot: AgentConfigSnapshotSummaryResponse
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):
@@ -433,22 +400,10 @@ 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_sets: list[AgentComposerKnowledgeSetCandidateResponse] = Field(default_factory=list)
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
+1 -2
View File
@@ -7,8 +7,7 @@ class ResponseModel(BaseModel):
model_config = ConfigDict(
from_attributes=True,
extra="ignore",
validate_by_name=True,
validate_by_alias=True,
populate_by_name=True,
serialize_by_alias=True,
protected_namespaces=(),
)
+3 -21
View File
@@ -2,10 +2,9 @@
from __future__ import annotations
import argparse
import sys
_DIAGNOSTIC_PREFIXES = ("ERROR ", "WARN ", "WARNING ")
_DIAGNOSTIC_PREFIXES = ("ERROR ", "WARNING ")
_LOCATION_PREFIX = "-->"
@@ -14,7 +13,7 @@ def extract_diagnostics(raw_output: str) -> str:
The full pyrefly output includes code excerpts and carets, which create noisy
diffs. This helper keeps only:
- diagnostic headline lines (``ERROR ...`` / ``WARN ...`` / ``WARNING ...``)
- diagnostic headline lines (``ERROR ...`` / ``WARNING ...``)
- the following location line (``--> path:line:column``), when present
"""
@@ -37,28 +36,11 @@ def extract_diagnostics(raw_output: str) -> str:
return "\n".join(diagnostics) + "\n"
def render_diagnostics(raw_output: str, exit_code: int) -> str:
"""Render concise diagnostics and fall back to raw output on unmatched failures."""
diagnostics = extract_diagnostics(raw_output)
if diagnostics:
return diagnostics
if exit_code != 0:
return raw_output
return ""
def main() -> int:
"""Read pyrefly output from stdin and print normalized diagnostics."""
parser = argparse.ArgumentParser()
parser.add_argument("--status", type=int, default=0)
args = parser.parse_args()
raw_output = sys.stdin.read()
sys.stdout.write(render_diagnostics(raw_output, exit_code=args.status))
sys.stdout.write(extract_diagnostics(raw_output))
return 0
@@ -1,5 +1,16 @@
"""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
@@ -22,8 +33,10 @@ 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,7 +18,8 @@ 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))
batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False, server_default=""))
batch_op.alter_column("role", server_default=None)
def downgrade():
@@ -6,9 +6,15 @@ Create Date: 2026-06-18 23:00:00.000000
"""
import sqlalchemy as sa
from __future__ import annotations
import json
from typing import Any
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy.engine.mock import MockConnection
# revision identifiers, used by Alembic.
revision = "b2515f9d4c2a"
@@ -31,9 +37,46 @@ 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
@@ -1,26 +0,0 @@
"""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")
@@ -1,56 +0,0 @@
"""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")
@@ -1,30 +0,0 @@
"""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")
@@ -1,37 +0,0 @@
"""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,8 +10,6 @@ from .account import (
)
from .agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@@ -156,8 +154,6 @@ __all__ = [
"AccountStatus",
"AccountTrialAppRecord",
"Agent",
"AgentConfigDraft",
"AgentConfigDraftType",
"AgentConfigRevision",
"AgentConfigRevisionOperation",
"AgentConfigSnapshot",
+3 -71
View File
@@ -85,17 +85,6 @@ 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):
@@ -145,7 +134,6 @@ 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",
@@ -174,30 +162,12 @@ 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
)
@@ -240,44 +210,6 @@ 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.
@@ -423,9 +355,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.
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.
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.
The snapshot is runtime state returned by Agent backend, kept separate from
Agent Soul snapshots and workflow node-job config.
+8 -178
View File
@@ -2,11 +2,10 @@ from __future__ import annotations
import re
from enum import StrEnum
from typing import Annotated, Any, Final, Literal, Self
from typing import Annotated, Any, Final, Literal
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
@@ -162,11 +161,6 @@ 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")
@@ -242,161 +236,17 @@ class AgentCliToolConfig(AgentFlexibleConfig):
inferred_from: str | None = Field(default=None, max_length=255)
class AgentKnowledgeDatasetConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):
id: str | None = Field(default=None, max_length=255)
name: str | None = Field(default=None, max_length=255)
description: 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"]
class AgentKnowledgeQueryConfig(AgentFlexibleConfig):
query: str | None = None
top_k: int | None = Field(default=None, ge=1)
score_threshold: float | None = Field(default=None, ge=0, le=1)
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
score_threshold_enabled: bool | None = None
class AgentHumanContactConfig(AgentFlexibleConfig):
@@ -603,28 +453,9 @@ class AgentSoulToolsConfig(BaseModel):
class AgentSoulKnowledgeConfig(BaseModel):
"""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
datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
query_mode: AgentKnowledgeQueryMode | None = None
query_config: AgentKnowledgeQueryConfig = Field(default_factory=AgentKnowledgeQueryConfig)
class AgentSoulHumanConfig(BaseModel):
@@ -682,7 +513,6 @@ 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
+3 -10
View File
@@ -487,15 +487,9 @@ class App(Base):
agent = db.session.scalar(
select(Agent).where(
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.app_id == self.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
)
@@ -2189,7 +2183,6 @@ 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="")
+15 -387
View File
@@ -465,83 +465,6 @@ 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
@@ -933,26 +856,6 @@ 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)
@@ -3864,7 +3767,6 @@ 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 |
@@ -12271,14 +12173,9 @@ Default namespace
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes |
| 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 |
@@ -12313,7 +12210,6 @@ 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 |
@@ -12322,7 +12218,6 @@ 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 |
@@ -12335,7 +12230,7 @@ Default namespace
| name | string | | Yes |
| permission_keys | [ string ] | | No |
| role | string | | No |
| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No |
| site | [Site](#site) | | No |
| tags | [ [Tag](#tag) ] | | No |
| tracing | [JSONValue](#jsonvalue) | | No |
| updated_at | integer | | No |
@@ -12378,7 +12273,6 @@ 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 |
@@ -12386,7 +12280,6 @@ 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 |
@@ -12445,27 +12338,6 @@ 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.
@@ -12528,18 +12400,10 @@ 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
@@ -12592,25 +12456,6 @@ 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 |
@@ -12626,7 +12471,7 @@ Risk marker for CLI tool bootstrap commands.
| cli_tools | [ [AgentCliToolConfig](#agentclitoolconfig) ] | | No |
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| knowledge_sets | [ [AgentComposerKnowledgeSetCandidateResponse](#agentcomposerknowledgesetcandidateresponse) ] | | No |
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
#### AgentComposerSoulLockResponse
@@ -12645,28 +12490,6 @@ 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.
@@ -12716,8 +12539,6 @@ 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
@@ -12972,12 +12793,10 @@ 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 |
@@ -13046,57 +12865,14 @@ the current roster/workflow APIs scoped to Dify Agent.
| id | string | | No |
| name | string | | No |
#### AgentKnowledgeMetadataCondition
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| 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 |
| query | string | | No |
| score_threshold | number | | No |
| score_threshold_enabled | boolean | | No |
| top_k | integer | | No |
#### AgentKnowledgeQueryMode
@@ -13104,59 +12880,6 @@ set owns its own query policy, so ``user_query`` must carry an explicit
| ---- | ---- | ----------- | -------- |
| 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 |
@@ -13340,21 +13063,6 @@ section may be empty, which is how callers express "no knowledge layer".
| ---- | ---- | ----------- | -------- |
| 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 |
@@ -13412,11 +13120,9 @@ section may be empty, which is how callers express "no knowledge layer".
| 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 |
@@ -13484,27 +13190,6 @@ 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 |
@@ -13531,7 +13216,6 @@ 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 |
@@ -13586,13 +13270,6 @@ 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 |
@@ -13602,17 +13279,11 @@ 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 |
| ---- | ---- | ----------- | -------- |
| sets | [ [AgentKnowledgeSetConfig](#agentknowledgesetconfig) ] | | No |
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
| query_config | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | No |
| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No |
#### AgentSoulMemoryConfig
@@ -13761,6 +13432,7 @@ Soft lifecycle state for Agent records.
| created_at | integer | | No |
| files | [ string ] | | Yes |
| id | string | | Yes |
| message_chain_id | string | | No |
| message_id | string | | Yes |
| observation | string | | No |
| position | integer | | Yes |
@@ -14085,36 +13757,6 @@ 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 |
@@ -14140,7 +13782,7 @@ Enum class for api provider schema type.
| model_config | [ModelConfig](#modelconfig) | | No |
| name | string | | Yes |
| permission_keys | [ string ] | | No |
| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No |
| site | [Site](#site) | | No |
| tags | [ [Tag](#tag) ] | | No |
| tracing | [JSONValue](#jsonvalue) | | No |
| updated_at | integer | | No |
@@ -14284,7 +13926,6 @@ 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 |
@@ -14312,7 +13953,6 @@ 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 |
@@ -14546,7 +14186,6 @@ 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 |
@@ -14904,8 +14543,8 @@ Enum class for configurate method of provider model.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| annotation_create_account | [SimpleAccount](#simpleaccount) | | No |
| annotation_id | string | | Yes |
| created_at | integer | | No |
| id | string | | Yes |
#### ConversationDetail
@@ -17074,7 +16713,6 @@ Input field definition for snippet parameters.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
@@ -17444,7 +17082,6 @@ Enum class for large language model mode.
| agent_thoughts | [ [AgentThought](#agentthought) ] | | No |
| annotation | [ConversationAnnotation](#conversationannotation) | | No |
| annotation_hit_history | [ConversationAnnotationHitHistory](#conversationannotationhithistory) | | No |
| answer | string | | Yes |
| answer_tokens | integer | | No |
| conversation_id | string | | Yes |
| created_at | integer | | No |
@@ -17458,11 +17095,12 @@ Enum class for large language model mode.
| inputs | object | | Yes |
| message | [JSONValue](#jsonvalue) | | No |
| message_files | [ [MessageFile](#messagefile) ] | | No |
| message_metadata_dict | [JSONValue](#jsonvalue) | | No |
| message_tokens | integer | | No |
| metadata | [JSONValue](#jsonvalue) | | No |
| parent_message_id | string | | No |
| provider_response_latency | number | | No |
| query | string | | Yes |
| re_sign_file_url_answer | string | | Yes |
| status | string | | Yes |
| workflow_run_id | string | | No |
@@ -19627,7 +19265,6 @@ 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 |
@@ -20715,12 +20352,6 @@ 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 |
@@ -20729,11 +20360,8 @@ 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 |
-6
View File
@@ -990,12 +990,6 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
| last_used_at | string | | No |
| prefix | string | | Yes |
#### SimpleResultResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### SupportedAppType
App types the ``app`` usage face (``get app``) lists and filters.
-1
View File
@@ -3999,7 +3999,6 @@ 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,7 +1006,6 @@ 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 |
+12 -28
View File
@@ -25,7 +25,6 @@ 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
@@ -140,34 +139,19 @@ def soul_candidates(
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
dataset_ids = list_agent_soul_knowledge_dataset_ids(soul)
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
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(
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(
{
"id": knowledge_set.id,
"name": knowledge_set.name,
"description": knowledge_set.description,
"datasets": datasets,
"missing_dataset_ids": missing_dataset_ids,
"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,
}
)
@@ -177,7 +161,7 @@ def soul_candidates(
lists = {
"dify_tools": dify_tools,
"cli_tools": cli_tools,
"knowledge_sets": knowledge_sets,
"knowledge_datasets": knowledge_datasets,
"human_contacts": human_contacts,
}
capped: dict[str, list[dict[str, Any]]] = {}
+115 -550
View File
@@ -11,8 +11,6 @@ from libs.helper import to_timestamp
from models import Account
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@@ -39,10 +37,6 @@ 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 (
@@ -96,68 +90,26 @@ 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, snapshot_id: str | None = None
) -> dict[str, Any]:
def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> 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
)
return cls._get_version_if_present(
version = 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(
@@ -168,7 +120,6 @@ 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)
@@ -308,37 +259,31 @@ class AgentComposerService:
@classmethod
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
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,
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)
)
version = cls._get_version_if_present(
if not agent:
raise AgentNotFoundError()
version = cls._require_version(
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),
"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",
"agent_soul": version.config_snapshot_dict,
"save_options": [
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value,
],
}
@classmethod
@@ -347,17 +292,22 @@ class AgentComposerService:
) -> dict[str, Any]:
if payload.variant != ComposerVariant.AGENT_APP:
raise ValueError("Agent App composer endpoint only accepts agent_app variant")
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)
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
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)
)
if not agent:
agent = Agent(
tenant_id=tenant_id,
@@ -367,7 +317,6 @@ 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,
@@ -378,59 +327,35 @@ 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,
)
@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."
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,
)
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,
)
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
db.session.commit()
state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id)
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
state["validation"] = cls.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
@@ -438,214 +363,6 @@ 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,
@@ -655,15 +372,19 @@ class AgentComposerService:
agent_id: str | None = None,
) -> dict[str, Any]:
"""ENG-617 soft findings, with DB-backed dataset and drive mention checks."""
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,
)
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)
if agent_id and payload.agent_soul is not None:
findings["warnings"].extend(
cls._drive_mention_findings(
@@ -674,24 +395,6 @@ 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)."""
@@ -806,7 +509,7 @@ class AgentComposerService:
soul_lists, soul_truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
dataset_lookup=lambda ids: cls._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
@@ -826,14 +529,14 @@ class AgentComposerService:
return response.model_dump(mode="json")
@classmethod
def get_agent_app_candidates(cls, *, tenant_id: str, agent_id: str, user_id: str) -> dict[str, Any]:
def get_agent_app_candidates(cls, *, tenant_id: str, app_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_soul(tenant_id=tenant_id, agent_id=agent_id)
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
soul_lists, truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
dataset_lookup=lambda ids: cls._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(
@@ -865,18 +568,24 @@ class AgentComposerService:
return cls._parse_soul_snapshot(version)
@classmethod
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)
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)
)
if agent is None:
return None
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,
version = cls._get_version_if_present(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
return AgentSoulConfig.model_validate(draft.config_snapshot_dict)
return cls._parse_soul_snapshot(version)
@staticmethod
def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
@@ -920,6 +629,30 @@ 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.
@@ -1047,7 +780,6 @@ 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
@@ -1146,7 +878,6 @@ 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:
@@ -1177,7 +908,6 @@ 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
@@ -1298,15 +1028,6 @@ 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}",
@@ -1319,7 +1040,6 @@ 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,
@@ -1338,7 +1058,6 @@ 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
@@ -1486,7 +1205,6 @@ 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
@@ -1567,145 +1285,6 @@ 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(
@@ -1892,12 +1471,6 @@ 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
@@ -1906,15 +1479,7 @@ 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_knowledge_set_ids: set[str] | None = None,
existing_dataset_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-set
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
mentions with a placeholder name (0522 consensus) instead of dropping or
rejecting them. With ``existing_knowledge_set_ids`` provided, mentions
that no longer exist in the current Agent Soul surface as placeholders too.
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
deleted datasets 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_knowledge_set_ids is not None and mention.ref_id not in existing_knowledge_set_ids
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
)
if dangling:
placeholders.append(
-63
View File
@@ -1,63 +0,0 @@
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]
+35 -114
View File
@@ -1,26 +1,23 @@
"""Prompt mention and workflow-marker parsing helpers for Agent surfaces.
"""Prompt mention (slash-reference) serialization contract — ENG-616.
Slash-menu insertions are stored inline as mention tokens:
Slash-menu insertions are stored inline in the plain-string prompt as tokens:
[§<kind>:<id>[:<label>]§]
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.
``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 ``:``.
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.
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.
"""
from __future__ import annotations
@@ -50,16 +47,13 @@ class MentionKind(StrEnum):
MENTION_PATTERN = re.compile(
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>[^§]*?))?§)"
r"\[§(skill|file|tool|cli_tool|knowledge|human|node_output|output):([^:§]+?)(?::([^§]*?))?§\]"
)
# 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.
@@ -77,8 +71,7 @@ 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 persisted workflow task prompt may only reference
# run-scoped ones.
# soul-owned entities; the workflow job prompt may only reference run-scoped ones.
SOUL_PROMPT_ALLOWED_KINDS = frozenset(
{
MentionKind.SKILL,
@@ -90,9 +83,6 @@ 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)
@@ -110,24 +100,18 @@ 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 ""):
kind, ref_id, label = _mention_groups(match)
ref_id = match.group(2)
label = match.group(3)
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(kind),
kind=MentionKind(match.group(1)),
ref_id=ref_id,
label=label or None,
start=match.start(),
@@ -146,13 +130,13 @@ def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
return prompt
def _replace(match: re.Match[str]) -> str:
kind, ref_id, label = _mention_groups(match)
label = label or None
ref_id = match.group(2)
label = match.group(3) 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(kind),
kind=MentionKind(match.group(1)),
ref_id=ref_id,
label=label,
start=match.start(),
@@ -177,48 +161,6 @@ 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."""
@@ -269,9 +211,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 knowledge_set in agent_soul.knowledge.sets:
if mention.ref_id == knowledge_set.id:
return knowledge_set.name or knowledge_set.id
for dataset in agent_soul.knowledge.datasets:
if mention.ref_id == dataset.id:
return dataset.name or dataset.id
case MentionKind.HUMAN:
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
case _:
@@ -282,17 +224,14 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionResolver:
"""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``.
"""
"""Resolve job-surface mentions. ``node_output`` expands to the stored
reference name only values stay in the Workflow context block (design §4.2)."""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
case MentionKind.NODE_OUTPUT:
for ref in node_job.previous_node_output_refs:
selector = normalize_previous_node_output_selector(ref)
selector = _selector_from_ref(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:
@@ -317,27 +256,14 @@ def _resolve_human_contact(contacts: list[AgentHumanContactConfig], ref_id: str)
return 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.
"""
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] | None:
for candidate in (ref.selector, ref.variable_selector, ref.value_selector):
if isinstance(candidate, list) and len(candidate) >= 2:
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 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
return None
@@ -349,18 +275,13 @@ __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",
]
+43 -200
View File
@@ -3,14 +3,11 @@ 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,
@@ -24,7 +21,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, AppModelConfig, Conversation, IconType
from models.model import App, AppMode, 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
@@ -86,7 +83,7 @@ class AgentRosterService:
agent: Agent,
active_version: AgentConfigSnapshot | None = None,
published_references: list[AgentReferencingWorkflow] | None = None,
active_config_is_published: bool | None = None,
active_config_is_published: bool = False,
) -> dict[str, Any]:
published_references = published_references or []
return {
@@ -101,16 +98,12 @@ 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": agent.active_config_is_published
if active_config_is_published is None
else active_config_is_published,
"active_config_is_published": active_config_is_published,
"status": agent.status.value,
"created_by": agent.created_by,
"updated_by": agent.updated_by,
@@ -328,7 +321,6 @@ 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()
@@ -371,7 +363,6 @@ 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,
)
@@ -403,58 +394,10 @@ 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."""
@@ -480,31 +423,8 @@ 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:
backing_app_id = self._ensure_workflow_agent_backing_app(agent=agent, account_id=account_id)
if not backing_app_id:
if not agent.app_id:
raise AgentNotFoundError()
mapping = self._session.scalar(
@@ -518,7 +438,7 @@ class AgentRosterService:
conversation_id = self._session.scalar(
select(Conversation.id).where(
Conversation.id == mapping.conversation_id,
Conversation.app_id == backing_app_id,
Conversation.app_id == agent.app_id,
Conversation.from_source == ConversationFromSource.CONSOLE,
Conversation.from_account_id == account_id,
Conversation.is_deleted.is_(False),
@@ -528,22 +448,21 @@ class AgentRosterService:
return conversation_id
mapping.conversation_id = self._create_agent_app_debug_conversation(
app_id=backing_app_id,
app_id=agent.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=backing_app_id,
app_id=agent.app_id,
account_id=account_id,
)
self._session.add(
AgentDebugConversation(
tenant_id=agent.tenant_id,
agent_id=agent.id,
app_id=backing_app_id,
app_id=agent.app_id,
account_id=account_id,
conversation_id=conversation_id,
)
@@ -560,6 +479,8 @@ 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,
)
)
@@ -580,20 +501,16 @@ 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:
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:
if agent is None or not agent.app_id:
raise AgentNotFoundError()
conversation_id = self._create_agent_app_debug_conversation(
app_id=backing_app_id,
app_id=agent.app_id,
account_id=account_id,
)
mapping = self._session.scalar(
@@ -608,13 +525,13 @@ class AgentRosterService:
AgentDebugConversation(
tenant_id=tenant_id,
agent_id=agent_id,
app_id=backing_app_id,
app_id=agent.app_id,
account_id=account_id,
conversation_id=conversation_id,
)
)
else:
mapping.app_id = backing_app_id
mapping.app_id = agent.app_id
mapping.conversation_id = conversation_id
self._session.flush()
if commit:
@@ -629,7 +546,11 @@ class AgentRosterService:
conversation_ids_by_agent_id: dict[str, str] = {}
changed = False
for agent in agents:
if agent.tenant_id != tenant_id or agent.status != AgentStatus.ACTIVE:
if (
agent.tenant_id != tenant_id
or agent.scope != AgentScope.ROSTER
or agent.source != AgentSource.AGENT_APP
):
continue
conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation(
agent=agent,
@@ -653,7 +574,7 @@ class AgentRosterService:
Agent.status == AgentStatus.ACTIVE,
)
).all()
return {agent.app_id: agent for agent in agents if agent.app_id and agent.id}
return {agent.app_id: agent for agent in agents if agent.app_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."""
@@ -704,59 +625,6 @@ 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,
*,
@@ -824,10 +692,10 @@ class AgentRosterService:
return target_app
@staticmethod
def _normalize_app_icon_type(icon_type: Any | None) -> str | None:
def _normalize_app_icon_type(icon_type: IconType | str | None) -> str | None:
if icon_type is None:
return None
if isinstance(icon_type, IconType) or hasattr(icon_type, "value"):
if isinstance(icon_type, IconType):
return icon_type.value
return icon_type
@@ -869,7 +737,6 @@ 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:
@@ -969,7 +836,6 @@ 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,
@@ -983,27 +849,16 @@ class AgentRosterService:
}
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
"""Return whether the normal shared draft has been published into the active snapshot."""
"""Return whether the Agent's current active snapshot is a visible published version."""
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 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
}
"""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}
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)
@@ -1102,38 +957,26 @@ class AgentRosterService:
raise AgentVersionNotFoundError()
version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
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(
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(
tenant_id=tenant_id,
agent_id=agent_id,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft_owner_key="",
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,
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": agent.active_config_snapshot_id or version.id,
"draft_config_id": draft.id,
"restored_version_id": version.id,
}
return {"result": "success", "active_config_snapshot_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)
+56 -165
View File
@@ -1,17 +1,15 @@
"""Validate and normalize uploaded Skill packages for drive standardization.
"""Validate + extract metadata from an uploaded Skill package (ENG-370).
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),
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.
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields) and
extracts a manifest consumed by drive standardization.
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 normalized package and commits the
canonical drive rows instead.
``SkillStandardizeService`` consumes the manifest and commits the canonical drive
rows instead.
"""
from __future__ import annotations
@@ -21,7 +19,6 @@ import io
import posixpath
import re
import zipfile
import zlib
import yaml
from pydantic import BaseModel
@@ -61,69 +58,10 @@ class SkillManifest(BaseModel):
hash: str # sha256 of the archive bytes
class NormalizedSkillPackage(BaseModel):
"""Canonical skill package bytes and metadata ready to store in agent drive."""
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."""
"""Validate Skill archives and extract their manifest."""
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:
def validate_and_extract(self, *, content: bytes, filename: str) -> SkillManifest:
self._check_extension(filename)
if not content:
raise SkillPackageError("empty_archive", "skill archive is empty", status_code=400)
@@ -131,90 +69,52 @@ class SkillPackageService:
raise SkillPackageError("archive_too_large", "skill archive exceeds size limit", status_code=400)
try:
return zipfile.ZipFile(io.BytesIO(content))
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
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)
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)
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,
)
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:
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(
"duplicate_member_path",
"skill archive contains duplicate normalized paths",
status_code=400,
"archive_too_large", "skill archive uncompressed size exceeds limit", 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
entry_path = self._find_skill_md(safe_paths)
skill_md = self._read_skill_md(archive, entry_path)
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()
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,
)
if member is None:
raise SkillPackageError("member_not_found", f"{member_path} not found in archive", status_code=400)
return archive.read(member)
@staticmethod
def _check_extension(filename: str) -> None:
@@ -245,26 +145,17 @@ class SkillPackageService:
return min(candidates, key=lambda p: (p.count("/"), len(p)))
@staticmethod
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:
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:
raise SkillPackageError("skill_md_too_large", "SKILL.md exceeds size limit", status_code=400)
@staticmethod
def _decode_skill_md(raw: bytes) -> str:
raw = archive.read(member)
try:
return raw.decode("utf-8")
except UnicodeDecodeError as exc:
@@ -302,4 +193,4 @@ class SkillPackageService:
return loaded if isinstance(loaded, dict) else {}
__all__ = ["NormalizedSkillPackage", "SkillManifest", "SkillPackageError", "SkillPackageService"]
__all__ = ["SkillManifest", "SkillPackageError", "SkillPackageService"]
+53 -29
View File
@@ -7,14 +7,19 @@ 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.
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.
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.
"""
from __future__ import annotations
import mimetypes
import posixpath
import re
from typing import Any
@@ -33,11 +38,7 @@ def slugify_skill_name(name: str) -> str:
class SkillStandardizeService:
"""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.
"""
"""Validate + standardize a Skill package into a per-agent drive upload result."""
def __init__(
self,
@@ -49,7 +50,6 @@ 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,23 +60,17 @@ class SkillStandardizeService:
user_id: str,
agent_id: str,
) -> dict[str, Any]:
"""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
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)
slug = slugify_skill_name(manifest.name)
# Drive-owned files: canonical SKILL.md and the full archive. The
# archive member tree is preserved in metadata and resolved lazily.
# Drive-owned files: canonical SKILL.md, every inspectable archive file,
# and the full archive for future restore/export.
md_tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=package.skill_md_bytes,
file_binary=skill_md_bytes,
mimetype="text/markdown",
filename=_SKILL_MD_NAME,
)
@@ -84,14 +78,38 @@ class SkillStandardizeService:
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=package.archive_bytes,
file_binary=content,
mimetype="application/zip",
filename=_FULL_ARCHIVE_NAME,
)
skill_md_key = f"{slug}/{_SKILL_MD_NAME}"
archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}"
committed_items = self._drive.commit(
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(
tenant_id=tenant_id,
user_id=user_id,
agent_id=agent_id,
@@ -112,17 +130,23 @@ class SkillStandardizeService:
file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id),
value_owned_by_drive=True,
),
*member_items,
],
)
self.last_committed_items = committed_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
)
return {
"skill": {
"name": manifest.name,
"description": manifest.description,
"path": slug,
"skill_md_key": skill_md_key,
"archive_key": archive_key,
"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"],
},
"manifest": manifest.model_dump(),
}
+3 -34
View File
@@ -18,18 +18,9 @@ from models.agent import (
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
WorkflowNodeJobConfig,
WorkflowPreviousNodeOutputRef,
)
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig
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,
@@ -48,10 +39,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 binding fields projected into node data.
"""Return draft graph with persisted Agent node job config projected into node data.
Workflow draft graph is the front-end's editing source of truth, while
runtime/publish reads WorkflowAgentNodeBinding. This
runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This
response-only projection keeps reads aligned without writing binding
details back into the stored graph JSON.
"""
@@ -73,18 +64,6 @@ 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
@@ -252,10 +231,6 @@ 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,
@@ -434,7 +409,6 @@ 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:
@@ -449,11 +423,6 @@ 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,
+28 -347
View File
@@ -19,18 +19,10 @@ 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
@@ -39,7 +31,6 @@ 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
@@ -55,7 +46,6 @@ _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):
@@ -375,7 +365,6 @@ 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,
@@ -609,7 +598,6 @@ 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:
@@ -629,14 +617,13 @@ 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 available_in_drive else None,
"available_in_drive": available_in_drive,
"drive_key": drive_key if drive_key in drive_keys else None,
"available_in_drive": drive_key in drive_keys,
}
)
if "SKILL.md" not in {file["path"] for file in files}:
@@ -857,209 +844,56 @@ class AgentDriveService:
return row
def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str:
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 row.file_kind == AgentDriveFileKind.TOOL_FILE:
tool_file = session.scalar(
select(ToolFile).where(ToolFile.id == row.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 == file_id, UploadFile.tenant_id == tenant_id)
select(UploadFile).where(UploadFile.id == row.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 _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)
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": key, "size": size, "truncated": truncated, "binary": True, "text": None}
try:
text = sample.decode("utf-8")
except UnicodeDecodeError:
if truncated:
try:
text = sample[:-3].decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
else:
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)
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
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)
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.
if b"\x00" in sample:
return {"key": row.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}
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}
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)
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,
)
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
url = self._resolve_download_url(
tenant_id=tenant_id,
file_kind=row.file_kind,
@@ -1071,159 +905,6 @@ 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,17 +96,6 @@ 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(session: Session | scoped_session, current_user: Account):
def is_tenant_owner_or_admin(current_user: Account):
tenant_id = current_user.current_tenant_id
join: TenantAccountJoin | None = session.scalar(
join: TenantAccountJoin | None = db.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, session: Session):
def process_tenant(cls, flask_app: Flask, tenant_id: str, days: int, batch: int):
with flask_app.app_context():
apps = session.scalars(select(App).where(App.tenant_id == tenant_id)).all()
apps = db.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,8 +375,7 @@ class ClearFreePlanTenantExpiredLogs:
or BillingService.get_info(tenant_id)["subscription"]["plan"] == CloudPlan.SANDBOX
):
# only process sandbox tenant
with sessionmaker(db.engine).begin() as session:
cls.process_tenant(flask_app, tenant_id, days, batch, session)
cls.process_tenant(flask_app, tenant_id, days, batch)
except Exception:
logger.exception("Failed to process tenant %s", tenant_id)
finally:
@@ -1,8 +1,9 @@
from collections.abc import Sequence
from sqlalchemy import or_, select
from sqlalchemy.orm import InstrumentedAttribute, Session, scoped_session
from sqlalchemy.orm import InstrumentedAttribute
from extensions.ext_database import db
from models.account import Account
from models.credential_permission import CredentialPermission
from models.enums import PermissionEnum
@@ -16,11 +17,9 @@ class CredentialPermissionService:
"""
@classmethod
def get_partial_member_list(
cls, session: Session | scoped_session, credential_id: str, credential_type: str
) -> Sequence[str]:
def get_partial_member_list(cls, credential_id: str, credential_type: str) -> Sequence[str]:
"""Return account_ids that have partial-member access to a credential."""
return session.scalars(
return db.session.scalars(
select(CredentialPermission.account_id).where(
CredentialPermission.credential_id == credential_id,
CredentialPermission.credential_type == credential_type,
+8 -9
View File
@@ -248,7 +248,7 @@ class DatasetService:
def get_datasets(
page,
per_page,
session: scoped_session | Session,
session: scoped_session | Session | None = None,
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,
):
"""Return visible datasets for a tenant, using the injected session for auxiliary permission lookups."""
session = session or db.session
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 = session.scalars(
dataset_permission = db.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, db.session)
DatasetService.check_dataset_permission(dataset, user)
# 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, db.session)
DatasetService.check_dataset_permission(dataset, user)
dataset_was_deleted.send(dataset)
@@ -1325,8 +1325,7 @@ class DatasetService:
return db.session.execute(stmt).scalar_one()
@staticmethod
def check_dataset_permission(dataset, user, session: scoped_session | Session):
"""Validate dataset access for a user, using the injected session for partial-member lookups."""
def check_dataset_permission(dataset, user):
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.")
@@ -1337,7 +1336,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 = session.scalar(
user_permission = db.session.scalar(
select(DatasetPermission)
.where(DatasetPermission.dataset_id == dataset.id, DatasetPermission.account_id == user.id)
.limit(1)
@@ -1729,7 +1728,7 @@ class DocumentService:
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user)
except NoPermissionError as e:
raise Forbidden(str(e))
+11 -7
View File
@@ -12,6 +12,7 @@ from sqlalchemy.exc import SQLAlchemyError
from configs import dify_config
from core.db.session_factory import session_factory
from core.rbac import RBACResourceWhitelistScope
from models import TenantAccountJoin, TenantAccountRole
from services.enterprise.base import EnterpriseRequest
@@ -435,6 +436,7 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
"app.acl.delete",
"app.acl.release_and_version",
"app.acl.monitor",
"app.acl.log_and_annotation",
"app.acl.access_config",
]
@@ -649,17 +651,18 @@ class ReplaceRoleBindings(_RBACModel):
class ReplaceMemberBindings(_RBACModel):
scope: str = "specific"
scope: RBACResourceWhitelistScope = RBACResourceWhitelistScope.SPECIFIC
@field_validator("scope")
@classmethod
def _normalize_scope(cls, value: Any) -> str:
def _normalize_scope(cls, value: Any) -> RBACResourceWhitelistScope:
scope = str(value or "").strip().lower()
if scope in {"", "specific"}:
return "specific"
if scope in {"all", "only_me"}:
return scope
raise ValueError(f"invalid scope: {value}")
if scope == "":
return RBACResourceWhitelistScope.SPECIFIC
try:
return RBACResourceWhitelistScope(scope)
except ValueError as exc:
raise ValueError(f"invalid scope: {value}") from exc
class DeleteMemberBindings(_RBACModel):
@@ -743,6 +746,7 @@ def _inner_call(
account_id=account_id,
json=json,
params=params,
timeout=dify_config.ENTERPRISE_RBAC_REQUEST_TIMEOUT,
)
-4
View File
@@ -31,10 +31,6 @@ 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,6 +173,14 @@ 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
+2 -21
View File
@@ -7,13 +7,10 @@ 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 ProviderConfigurationCacheSource, ProviderManager
from core.provider_manager import ProviderManager
from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.entities.provider_entities import (
@@ -316,10 +313,6 @@ 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
@@ -441,10 +434,6 @@ 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:
@@ -498,20 +487,12 @@ 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,11 +8,6 @@ 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
@@ -30,11 +25,9 @@ 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 threading import Lock
from typing import Any, NotRequired, TypedDict, cast
from typing import Any, TypedDict
import click
import pyarrow as pa
@@ -66,7 +59,6 @@ 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,
)
@@ -98,24 +90,10 @@ 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."""
@@ -149,7 +127,6 @@ 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
@@ -215,14 +192,11 @@ 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 = 10000,
batch_size: int = 100,
start_from: datetime.datetime | None = None,
end_before: datetime.datetime | None = None,
workers: int = 1,
@@ -264,10 +238,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")
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:
if start_from >= 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)
@@ -285,9 +259,6 @@ 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
@@ -351,23 +322,24 @@ class WorkflowRunArchiver:
if not runs_to_process:
continue
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
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)
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}, skipped_runs={result.skipped_run_count}, "
f"reason={result.error or 'already handled'})",
f"runs={result.run_count}, 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)
@@ -375,14 +347,15 @@ 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"skipped_runs={result.skipped_run_count}, tables={len(result.tables)}, "
f"object_size_bytes={result.object_size_bytes}, time={result.elapsed_time:.2f}s)",
f"tables={len(result.tables)}, object_size_bytes={result.object_size_bytes}, "
f"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(
@@ -520,35 +493,6 @@ 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,
@@ -559,7 +503,6 @@ 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,
@@ -574,36 +517,12 @@ 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
@@ -628,7 +547,6 @@ 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(
@@ -678,68 +596,49 @@ class WorkflowRunArchiver:
session: Session,
runs: Sequence[WorkflowRun],
) -> dict[str, list[dict[str, Any]]]:
"""Extract archived rows using Core mappings to avoid ORM hydration on large retention batches."""
"""Extract all archived table rows for a bundle."""
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]
table_data["workflow_app_logs"] = self._select_rows_by_column(
session,
WorkflowAppLog,
WorkflowAppLog.workflow_run_id,
run_ids,
)
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 = 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,
node_exec_records = list(
session.scalars(
select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.workflow_run_id.in_(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 = 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,
)
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]
table_data["workflow_trigger_logs"] = self._select_rows_by_column(
session,
WorkflowTriggerLog,
WorkflowTriggerLog.workflow_run_id,
run_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]
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
@@ -791,9 +690,6 @@ 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,
@@ -811,10 +707,6 @@ 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],
)
@@ -881,158 +773,6 @@ 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,7 +4,6 @@ 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(
db.session, provider.id, CredPermType.BUILTIN_TOOL_PROVIDER
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 ANY, MagicMock, patch
from unittest.mock import MagicMock, patch
import pyarrow as pa
import pyarrow.parquet as pq
@@ -14,24 +14,6 @@ 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"):
@@ -180,9 +162,7 @@ class TestBuildArchiveBundle:
class TestGenerateManifest:
def test_manifest_structure(self):
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)
archiver = WorkflowRunArchiver(days=90)
from services.retention.workflow_run.archive_paid_plan_workflow_run import TableStats
run = MagicMock()
@@ -217,10 +197,6 @@ 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"
@@ -352,21 +328,6 @@ 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()
@@ -399,47 +360,3 @@ 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
@@ -27,6 +27,7 @@ extend-select = ["ANN401", "ARG", "TID251"]
"controllers/web/test_wraps.py" = ["ARG"]
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG"]
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG"]
"models/test_account.py" = ["ARG"]
"models/test_conversation_message_inputs.py" = ["ARG"]
"models/test_conversation_status_count.py" = ["ARG"]
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG"]
@@ -343,13 +343,8 @@ class TestSiteEndpoints:
return flask_app_with_containers
def test_site_response_structure(self):
payload = AppSiteUpdatePayload(
title="My Site",
description="Test site",
input_placeholder="Ask me anything",
)
payload = AppSiteUpdatePayload(title="My Site", description="Test site")
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")
@@ -370,7 +365,6 @@ 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
@@ -383,13 +377,11 @@ class TestSiteEndpoints:
)
monkeypatch.setattr(site_module, "naive_utc_now", lambda: "now")
with app.test_request_context("/", json={"title": "My Site", "input_placeholder": "Ask me anything"}):
with app.test_request_context("/", json={"title": "My Site"}):
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()
@@ -406,7 +398,6 @@ 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, webapp_copyright_enabled=True)
features = FeatureModel(can_replace_logo=True)
return features
monkeypatch.setattr(

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