Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b9e03c2c1 | ||
|
|
b34eb8f24b | ||
|
|
625cfad888 | ||
|
|
3859367e3a | ||
|
|
92c0974e19 | ||
|
|
f7d859afac | ||
|
|
b522f7df40 | ||
|
|
1787145f2a | ||
|
|
f581dfc016 | ||
|
|
26291a0f42 | ||
|
|
80b9f5e083 | ||
|
|
bbebeec3dc | ||
|
|
6a69ec2a8a | ||
|
|
f874a6a019 | ||
|
|
bcdd52a27b | ||
|
|
32e9de8ae3 | ||
|
|
35c9e3e3d3 | ||
|
|
d37783c895 | ||
|
|
cceeabcb46 | ||
|
|
8536c232ba | ||
|
|
8383571c53 | ||
|
|
953b42ac67 | ||
|
|
4e069c1f23 | ||
|
|
09b5d63c39 | ||
|
|
8f2cd4e5b0 | ||
|
|
b57e5da995 | ||
|
|
daf13834a6 | ||
|
|
309777f8fc | ||
|
|
184670f49d | ||
|
|
63aa8af5c1 | ||
|
|
ee30e59871 | ||
|
|
0199589c3b | ||
|
|
7bb94cb6fe | ||
|
|
484633d261 | ||
|
|
a14310fc62 | ||
|
|
8218694691 | ||
|
|
17bee5fb32 | ||
|
|
446b3962c1 | ||
|
|
00b4cdc68e | ||
|
|
449b46b863 | ||
|
|
8d09b32cd5 | ||
|
|
52c106b532 | ||
|
|
5b8679468d | ||
|
|
4fbfedd287 | ||
|
|
051f4b32e3 | ||
|
|
e22fd9efd6 | ||
|
|
35eeb743d1 | ||
|
|
1dbda1463e | ||
|
|
3f2ef24755 |
@@ -25,6 +25,8 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
||||
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
|
||||
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
|
||||
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
|
||||
- Module README whitelist: `@/service/client`, `@/next/*`.
|
||||
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
|
||||
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
|
||||
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
|
||||
|
||||
@@ -78,11 +78,22 @@ def _filter_snapshot_to_specs(
|
||||
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
|
||||
|
||||
|
||||
def _shell_layer_deps(*, include_drive: bool) -> dict[str, str]:
|
||||
deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
if include_drive:
|
||||
deps["drive"] = DIFY_DRIVE_LAYER_ID
|
||||
return deps
|
||||
def _shell_layer_deps() -> dict[str, str]:
|
||||
return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
|
||||
|
||||
def _drive_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _shell_config_with_drive_ref(
|
||||
shell_config: DifyShellLayerConfig | None,
|
||||
drive_config: DifyDriveLayerConfig | None,
|
||||
) -> DifyShellLayerConfig:
|
||||
config = shell_config or DifyShellLayerConfig()
|
||||
if drive_config is None:
|
||||
return config
|
||||
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
|
||||
|
||||
|
||||
class AgentBackendModelConfig(BaseModel):
|
||||
@@ -263,14 +274,29 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = run_input.include_shell or run_input.drive_config is not None
|
||||
if include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): a config-only index;
|
||||
# the agent pulls listed entries through the back proxy by drive_ref.
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
deps=_drive_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
@@ -312,7 +338,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
@@ -336,21 +362,6 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context
|
||||
# so the agent server can mint per-command Agent Stub env, and on
|
||||
# drive when present so that env points at /mnt/drive/<drive_ref>.
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.output is not None:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -445,7 +456,7 @@ class AgentBackendRunRequestBuilder:
|
||||
name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "workflow_node_job"},
|
||||
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
|
||||
config=PromptLayerConfig(user=run_input.workflow_node_job_prompt),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
@@ -462,14 +473,29 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = run_input.include_shell or run_input.drive_config is not None
|
||||
if include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): a config-only index;
|
||||
# the agent pulls listed entries through the back proxy by drive_ref.
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
deps=_drive_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
@@ -513,7 +539,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
@@ -537,21 +563,6 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context
|
||||
# so the agent server can mint per-command Agent Stub env, and on
|
||||
# drive when present so that env points at /mnt/drive/<drive_ref>.
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.output is not None:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
|
||||
@@ -35,6 +35,12 @@ class WorkflowRunArchiveTenantPlan(TypedDict):
|
||||
unpaid_tenant_ids: list[str]
|
||||
|
||||
|
||||
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=datetime.UTC)
|
||||
return value.astimezone(datetime.UTC)
|
||||
|
||||
|
||||
def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
|
||||
if not prefixes:
|
||||
return []
|
||||
@@ -156,11 +162,16 @@ def _resolve_archive_time_range(
|
||||
raise click.UsageError("Choose either day offsets or explicit dates, not both.")
|
||||
if from_days_ago <= to_days_ago:
|
||||
raise click.UsageError("--from-days-ago must be greater than --to-days-ago.")
|
||||
now = datetime.datetime.now()
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
start_from = now - datetime.timedelta(days=from_days_ago)
|
||||
end_before = now - datetime.timedelta(days=to_days_ago)
|
||||
before_days = 0
|
||||
|
||||
if start_from is not None:
|
||||
start_from = _normalize_utc_datetime(start_from)
|
||||
if end_before is not None:
|
||||
end_before = _normalize_utc_datetime(end_before)
|
||||
|
||||
if start_from and end_before and start_from >= end_before:
|
||||
raise click.UsageError("--start-from must be earlier than --end-before.")
|
||||
|
||||
@@ -402,6 +413,13 @@ def archive_workflow_runs_plan(
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
click.echo(
|
||||
click.style(
|
||||
"fixed_archive_window="
|
||||
f"{start_from.isoformat() if start_from else 'unbounded'},{plan_end_before.isoformat()}",
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
click.echo("tenant_prefix,total_tenants,workflow_runs,workflow_node_executions,paid_tenants,unpaid_tenants")
|
||||
for row in rows:
|
||||
click.echo(
|
||||
@@ -451,7 +469,7 @@ def archive_workflow_runs_plan(
|
||||
default=None,
|
||||
help="Archive runs created before this timestamp (UTC if no timezone).",
|
||||
)
|
||||
@click.option("--batch-size", default=100, show_default=True, help="Maximum workflow runs per archive bundle.")
|
||||
@click.option("--batch-size", default=10000, show_default=True, help="Maximum workflow runs per archive bundle.")
|
||||
@click.option(
|
||||
"--workers",
|
||||
default=1,
|
||||
@@ -521,6 +539,7 @@ def archive_workflow_runs(
|
||||
)
|
||||
)
|
||||
|
||||
uses_relative_window = start_from is None and end_before is None
|
||||
try:
|
||||
before_days, start_from, end_before = _resolve_archive_time_range(
|
||||
before_days=before_days,
|
||||
@@ -546,6 +565,14 @@ def archive_workflow_runs(
|
||||
if delete_after_archive:
|
||||
click.echo(click.style("delete-after-archive is not supported by bundle archive.", fg="red"))
|
||||
return
|
||||
if uses_relative_window:
|
||||
click.echo(
|
||||
click.style(
|
||||
"Relative archive windows are evaluated at command start. For multi-day prefix/shard rollout, "
|
||||
"reuse absolute --start-from/--end-before values from archive-workflow-runs-plan.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
tenant_plan = _resolve_archive_tenant_ids_from_plan(
|
||||
|
||||
@@ -36,8 +36,8 @@ class AgentBackendConfig(BaseSettings):
|
||||
description=(
|
||||
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
|
||||
"into Agent runs. The declaration is an index only — the agent backend "
|
||||
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
|
||||
"until the agent backend registers the dify.drive layer type."
|
||||
"pulls the actual SKILL.md / files through the back proxy. Set this to "
|
||||
"false only when temporarily rolling back the drive integration."
|
||||
),
|
||||
default=False,
|
||||
default=True,
|
||||
)
|
||||
|
||||
@@ -183,6 +183,7 @@ class Site(BaseModel):
|
||||
description: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
default_language: str
|
||||
show_workflow_steps: bool
|
||||
|
||||
@@ -6,5 +6,15 @@ from services.agent.roster_service import AgentRosterService
|
||||
|
||||
|
||||
def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App:
|
||||
"""Resolve the hidden Agent App backing an Agent Console resource."""
|
||||
"""Resolve a roster Agent's public Agent App."""
|
||||
return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
|
||||
|
||||
def resolve_agent_runtime_app_model(*, tenant_id: str, agent_id: UUID) -> App:
|
||||
"""Resolve the App that backs an Agent runtime surface.
|
||||
|
||||
This accepts both roster Agent Apps and workflow-only inline Agents with a
|
||||
hidden backing App.
|
||||
"""
|
||||
|
||||
return AgentRosterService(db.session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -28,9 +28,15 @@ from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload
|
||||
from services.entities.agent_entities import (
|
||||
ComposerSavePayload,
|
||||
WorkflowAgentComposerQuery,
|
||||
WorkflowComposerCopyFromRosterPayload,
|
||||
)
|
||||
|
||||
register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload)
|
||||
register_schema_models(
|
||||
console_ns, ComposerSavePayload, WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentAppComposerResponse,
|
||||
@@ -41,27 +47,26 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str:
|
||||
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
|
||||
class WorkflowAgentComposerApi(Resource):
|
||||
@console_ns.response(
|
||||
200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]
|
||||
)
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowAgentComposerQuery))
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, app_model: App, node_id: str):
|
||||
query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True))
|
||||
return dump_response(
|
||||
WorkflowAgentComposerResponse,
|
||||
AgentComposerService.load_workflow_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
snapshot_id=query.snapshot_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -137,6 +142,7 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
def post(self, tenant_id: str, app_model: App, node_id: str):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
@@ -228,10 +234,9 @@ class AgentComposerApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return dump_response(
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id),
|
||||
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)),
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@@ -244,13 +249,12 @@ class AgentComposerApi(Resource):
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, account_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return dump_response(
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerService.save_agent_app_composer(
|
||||
AgentComposerService.save_agent_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=account_id,
|
||||
payload=payload,
|
||||
),
|
||||
@@ -268,9 +272,10 @@ class AgentComposerValidateApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID):
|
||||
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
@@ -290,12 +295,11 @@ class AgentComposerCandidatesApi(Resource):
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return dump_response(
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerService.get_agent_app_candidates(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
agent_id=str(agent_id),
|
||||
user_id=current_user_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy import func, select
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model, resolve_agent_runtime_app_model
|
||||
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
|
||||
from controllers.console.app.app import (
|
||||
AppDetailWithSite as GenericAppDetailWithSite,
|
||||
@@ -54,8 +54,10 @@ from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.agent import Agent, AgentStatus
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, IconType
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.errors import AgentNotFoundError
|
||||
from services.agent.observability_service import (
|
||||
AgentLogQueryParams,
|
||||
@@ -65,7 +67,7 @@ from services.agent.observability_service import (
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.agent_entities import RosterListQuery
|
||||
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
@@ -232,6 +234,8 @@ class AgentStatisticsQuery(BaseModel):
|
||||
|
||||
class AgentAppPartial(GenericAppPartial):
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
debug_conversation_id: str | None = None
|
||||
role: str | None = None
|
||||
active_config_is_published: bool = False
|
||||
@@ -241,6 +245,8 @@ class AgentAppPartial(GenericAppPartial):
|
||||
|
||||
class AgentAppDetailWithSite(GenericAppDetailWithSite):
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
debug_conversation_id: str | None = None
|
||||
role: str | None = None
|
||||
active_config_is_published: bool = False
|
||||
@@ -250,6 +256,36 @@ class AgentDebugConversationRefreshResponse(BaseModel):
|
||||
debug_conversation_id: str
|
||||
|
||||
|
||||
class AgentPublishPayload(BaseModel):
|
||||
version_note: str | None = Field(default=None, description="Optional note for this published Agent version")
|
||||
|
||||
|
||||
class AgentPublishResponse(BaseModel):
|
||||
result: str
|
||||
active_config_snapshot_id: str
|
||||
active_config_snapshot: dict[str, object] | None = None
|
||||
draft: dict[str, object] | None = None
|
||||
|
||||
|
||||
class AgentBuildDraftCheckoutPayload(BaseModel):
|
||||
force: bool = Field(default=False, description="Overwrite the existing current-user build draft")
|
||||
|
||||
|
||||
class AgentBuildDraftResponse(BaseModel):
|
||||
variant: str
|
||||
draft: dict[str, object]
|
||||
agent_soul: dict[str, object]
|
||||
|
||||
|
||||
class AgentBuildDraftApplyResponse(BaseModel):
|
||||
result: str
|
||||
draft: dict[str, object]
|
||||
|
||||
|
||||
class AgentSimpleResultResponse(BaseModel):
|
||||
result: str
|
||||
|
||||
|
||||
class AgentAppPagination(GenericAppPagination):
|
||||
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
|
||||
validation_alias=AliasChoices("items", "data")
|
||||
@@ -261,6 +297,9 @@ register_schema_models(
|
||||
AgentAppCreatePayload,
|
||||
AgentAppUpdatePayload,
|
||||
AgentAppCopyPayload,
|
||||
AgentPublishPayload,
|
||||
AgentBuildDraftCheckoutPayload,
|
||||
ComposerSavePayload,
|
||||
AgentApiStatusPayload,
|
||||
AgentInviteOptionsQuery,
|
||||
AgentLogsQuery,
|
||||
@@ -277,6 +316,10 @@ register_response_schema_models(
|
||||
AgentAppDetailWithSite,
|
||||
AgentAppPartial,
|
||||
AgentDebugConversationRefreshResponse,
|
||||
AgentPublishResponse,
|
||||
AgentBuildDraftResponse,
|
||||
AgentBuildDraftApplyResponse,
|
||||
AgentSimpleResultResponse,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentConfigSnapshotRestoreResponse,
|
||||
@@ -294,7 +337,7 @@ def _agent_roster_service() -> AgentRosterService:
|
||||
return AgentRosterService(db.session)
|
||||
|
||||
|
||||
def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
|
||||
def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: str | None = None) -> dict:
|
||||
"""Serialize an Agent App detail using roster-only DTOs.
|
||||
|
||||
`/agent` responses are roster-shaped rather than raw app-shaped: `id`
|
||||
@@ -311,11 +354,23 @@ def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
|
||||
|
||||
roster_service = _agent_roster_service()
|
||||
payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
|
||||
agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
|
||||
agent = (
|
||||
db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
if agent_id
|
||||
else roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
|
||||
)
|
||||
if not agent:
|
||||
raise AgentNotFoundError()
|
||||
payload.pop("bound_agent_id", None)
|
||||
payload["app_id"] = str(app_model.id)
|
||||
payload["app_id"] = agent.app_id
|
||||
payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent)
|
||||
payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id)
|
||||
payload["id"] = agent.id
|
||||
payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
@@ -365,6 +420,8 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_u
|
||||
agent = agents_by_app_id.get(app_id)
|
||||
if agent:
|
||||
item["app_id"] = app_id
|
||||
item["backing_app_id"] = agent.backing_app_id or app_id
|
||||
item["hidden_app_backed"] = False
|
||||
item["id"] = agent.id
|
||||
item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id)
|
||||
item["role"] = agent.role or ""
|
||||
@@ -516,8 +573,8 @@ class AgentAppApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _serialize_agent_app_detail(app_model, current_user=current_user)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _serialize_agent_app_detail(app_model, current_user=current_user, agent_id=str(agent_id))
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@@ -583,6 +640,112 @@ class AgentDebugConversationRefreshApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/publish")
|
||||
class AgentPublishApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentPublishPayload.__name__])
|
||||
@console_ns.response(200, "Agent draft published", console_ns.models[AgentPublishResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentPublishPayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.publish_agent_app_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
version_note=args.version_note,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft/checkout")
|
||||
class AgentBuildDraftCheckoutApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentBuildDraftCheckoutPayload.__name__])
|
||||
@console_ns.response(200, "Agent build draft checked out", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.checkout_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
force=args.force,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft")
|
||||
class AgentBuildDraftApi(Resource):
|
||||
@console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.load_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(200, "Agent build draft saved", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.save_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
@console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.discard_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft/apply")
|
||||
class AgentBuildDraftApplyApi(Resource):
|
||||
@console_ns.response(200, "Agent build draft applied", console_ns.models[AgentBuildDraftApplyResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.apply_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/copy")
|
||||
class AgentAppCopyApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__])
|
||||
@@ -712,7 +875,7 @@ class AgentLogsApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
|
||||
query_data["sources"] = _multi_query_values("sources", "source")
|
||||
query_data["statuses"] = _multi_query_values("statuses", "status")
|
||||
@@ -749,7 +912,7 @@ class AgentLogMessagesApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
|
||||
query_data["sources"] = _multi_query_values("sources", "source")
|
||||
query_data["statuses"] = _multi_query_values("statuses", "status")
|
||||
@@ -786,7 +949,7 @@ class AgentLogSourcesApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id))
|
||||
return dump_response(AgentLogSourceListResponse, payload)
|
||||
|
||||
@@ -805,7 +968,7 @@ class AgentStatisticsSummaryApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
timezone = current_user.timezone or "UTC"
|
||||
start, end = _parse_observability_time_range(query.start, query.end, current_user)
|
||||
|
||||
@@ -13,7 +13,7 @@ from controllers.common.schema import (
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -351,7 +351,7 @@ class AgentSkillUploadByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@@ -394,7 +394,7 @@ class AgentDriveFilesByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file_by_agent")
|
||||
@@ -407,7 +407,7 @@ class AgentDriveFilesByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
|
||||
|
||||
|
||||
@@ -454,7 +454,7 @@ class AgentSkillByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False)
|
||||
|
||||
|
||||
@@ -494,7 +494,7 @@ class AgentSkillInferToolsByAgentApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
@@ -87,7 +87,7 @@ class AgentAppFeatureConfigResource(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
new_app_model_config = AgentAppFeatureConfigService.update_features(
|
||||
|
||||
@@ -22,7 +22,7 @@ from controllers.common.schema import (
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from fields.base import ResponseModel
|
||||
@@ -144,7 +144,7 @@ class AgentAppSandboxListResource(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxListQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().list_files(
|
||||
@@ -169,7 +169,7 @@ class AgentAppSandboxReadResource(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxFileQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().read_file(
|
||||
@@ -194,7 +194,7 @@ class AgentAppSandboxUploadResource(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
|
||||
try:
|
||||
result = AgentAppSandboxService().upload_file(
|
||||
|
||||
@@ -25,7 +25,7 @@ from controllers.common.schema import (
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from fields.base import ResponseModel
|
||||
@@ -182,7 +182,7 @@ class AgentDriveListByAgentApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveListByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
|
||||
except AgentDriveError as exc:
|
||||
@@ -201,7 +201,7 @@ class AgentDriveSkillListByAgentApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
except AgentDriveError as exc:
|
||||
@@ -220,7 +220,7 @@ class AgentDriveSkillInspectByAgentApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID, skill_path: str):
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
@@ -245,7 +245,7 @@ class AgentDrivePreviewByAgentApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
@@ -264,7 +264,7 @@ class AgentDriveDownloadByAgentApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
|
||||
@@ -331,7 +331,7 @@ class ModelConfig(ResponseModel):
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class Site(ResponseModel):
|
||||
class AppDetailSiteResponse(ResponseModel):
|
||||
access_token: str | None = Field(default=None, validation_alias="code")
|
||||
code: str | None = None
|
||||
title: str | None = None
|
||||
@@ -345,6 +345,7 @@ class Site(ResponseModel):
|
||||
customize_domain: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
customize_token_strategy: str | None = None
|
||||
prompt_public: bool | None = None
|
||||
@@ -461,7 +462,7 @@ class AppDetailWithSite(AppDetail):
|
||||
api_base_url: str | None = None
|
||||
max_active_requests: int | None = None
|
||||
deleted_tools: list[DeletedTool] = Field(default_factory=list)
|
||||
site: Site | None = None
|
||||
site: AppDetailSiteResponse | None = None
|
||||
# For Agent App type: the roster Agent backing this app (None otherwise).
|
||||
bound_agent_id: str | None = None
|
||||
# For Agent App responses exposed through /agent.
|
||||
@@ -546,7 +547,7 @@ register_schema_models(
|
||||
WorkflowPartial,
|
||||
ModelConfigPartial,
|
||||
ModelConfig,
|
||||
Site,
|
||||
AppDetailSiteResponse,
|
||||
DeletedTool,
|
||||
AppDetail,
|
||||
AppExportResponse,
|
||||
|
||||
@@ -11,7 +11,7 @@ import services
|
||||
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
@@ -93,6 +93,10 @@ class ChatMessagePayload(BaseMessagePayload):
|
||||
query: str = Field(..., description="User query")
|
||||
conversation_id: str | None = Field(default=None, description="Conversation ID")
|
||||
parent_message_id: str | None = Field(default=None, description="Parent message ID")
|
||||
draft_type: Literal["draft", "debug_build"] = Field(
|
||||
default="draft",
|
||||
description="Agent App debug config source. Use debug_build while the Agent is in build mode.",
|
||||
)
|
||||
|
||||
@field_validator("conversation_id", "parent_message_id")
|
||||
@classmethod
|
||||
@@ -218,7 +222,7 @@ class AgentChatMessageApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _create_chat_message(
|
||||
current_tenant_id=current_tenant_id,
|
||||
current_user=current_user,
|
||||
@@ -254,7 +258,7 @@ class AgentChatMessageStopApi(Resource):
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes
|
||||
from controllers.common.fields import SimpleResultResponse, TextFileResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import (
|
||||
CompletionRequestError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
@@ -214,7 +214,7 @@ class AgentChatMessageListApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _list_chat_messages(app_model=app_model, current_user=current_user)
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ class AgentMessageFeedbackApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _update_message_feedback(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ class AgentMessageSuggestedQuestionApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ class AgentMessageApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _get_message_detail(app_model=app_model, message_id=message_id)
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class AppSiteUpdatePayload(BaseModel):
|
||||
customize_domain: str | None = Field(default=None)
|
||||
copyright: str | None = Field(default=None)
|
||||
privacy_policy: str | None = Field(default=None)
|
||||
input_placeholder: str | None = Field(default=None)
|
||||
custom_disclaimer: str | None = Field(default=None)
|
||||
customize_token_strategy: Literal["must", "allow", "not_allow"] | None = Field(default=None)
|
||||
prompt_public: bool | None = Field(default=None)
|
||||
@@ -66,6 +67,7 @@ class AppSiteResponse(ResponseModel):
|
||||
customize_domain: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
customize_token_strategy: str
|
||||
prompt_public: bool
|
||||
@@ -110,6 +112,7 @@ class AppSite(Resource):
|
||||
"customize_domain",
|
||||
"copyright",
|
||||
"privacy_policy",
|
||||
"input_placeholder",
|
||||
"custom_disclaimer",
|
||||
"customize_token_strategy",
|
||||
"prompt_public",
|
||||
|
||||
@@ -16,6 +16,7 @@ from controllers.console.wraps import (
|
||||
with_current_user,
|
||||
)
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
@@ -50,7 +51,7 @@ class Subscription(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True))
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id)
|
||||
|
||||
|
||||
@@ -64,7 +65,7 @@ class Invoices(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
return BillingService.get_invoices(current_user.email, current_tenant_id)
|
||||
|
||||
|
||||
|
||||
@@ -602,7 +602,7 @@ class DatasetApi(Resource):
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
@@ -774,7 +774,7 @@ class DatasetQueryApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -915,7 +915,7 @@ class DatasetRelatedAppListApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -1194,7 +1194,7 @@ class DatasetPermissionUserListApi(Resource):
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ class DocumentResource(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -206,7 +206,7 @@ class DocumentResource(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -247,7 +247,7 @@ class GetProcessRuleApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -322,7 +322,7 @@ class DatasetDocumentListApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -431,7 +431,7 @@ class DatasetDocumentListApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -1173,7 +1173,7 @@ class DocumentStatusApi(DocumentResource):
|
||||
DatasetService.check_dataset_model_setting(dataset)
|
||||
|
||||
# check user's permission
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
document_ids = request.args.getlist("document_id")
|
||||
|
||||
@@ -1440,7 +1440,7 @@ class DocumentGenerateSummaryApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -1537,7 +1537,7 @@ class DocumentSummaryStatusApi(DocumentResource):
|
||||
|
||||
# Check permissions
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ class DatasetDocumentSegmentListApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -302,7 +302,7 @@ class DatasetDocumentSegmentListApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
SegmentService.delete_segments(segment_ids, document, dataset)
|
||||
@@ -345,7 +345,7 @@ class DatasetDocumentSegmentApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
||||
@@ -421,7 +421,7 @@ class DatasetDocumentSegmentAddApi(Resource):
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -494,7 +494,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -550,7 +550,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
SegmentService.delete_segment(segment, document, dataset)
|
||||
@@ -687,7 +687,7 @@ class ChildChunkAddApi(Resource):
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -789,7 +789,7 @@ class ChildChunkAddApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -862,7 +862,7 @@ class ChildChunkUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
try:
|
||||
@@ -930,7 +930,7 @@ class ChildChunkUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
|
||||
@@ -382,7 +382,7 @@ class ExternalKnowledgeHitTestingApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ class DatasetsHitTestingBase:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ class DatasetMetadataCreateApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata = MetadataService.create_metadata(
|
||||
db.session(), dataset_id_str, metadata_args, current_user, current_tenant_id
|
||||
@@ -108,7 +108,7 @@ class DatasetMetadataApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata = MetadataService.update_metadata_name(
|
||||
db.session(), dataset_id_str, metadata_id_str, name, current_user, current_tenant_id
|
||||
@@ -128,7 +128,7 @@ class DatasetMetadataApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
|
||||
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
|
||||
@@ -165,7 +165,7 @@ class DatasetMetadataBuiltInFieldActionApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
@@ -194,7 +194,7 @@ class DocumentMetadataEditApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata_args = MetadataOperationData.model_validate(console_ns.payload or {})
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
@@ -352,7 +353,7 @@ class ModelProviderPaymentCheckoutUrlApi(Resource):
|
||||
def get(self, current_tenant_id: str, current_user: Account, provider: str):
|
||||
if provider != "anthropic":
|
||||
raise ValueError(f"provider name {provider} is invalid")
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
data = BillingService.get_model_provider_payment_link(
|
||||
provider_name=provider,
|
||||
tenant_id=current_tenant_id,
|
||||
|
||||
@@ -14,11 +14,12 @@ api = ExternalApi(
|
||||
|
||||
files_ns = Namespace("files", description="File operations", path="/")
|
||||
|
||||
from . import image_preview, tool_files, upload
|
||||
from . import agent_drive_archive, image_preview, tool_files, upload
|
||||
|
||||
api.add_namespace(files_ns)
|
||||
|
||||
__all__ = [
|
||||
"agent_drive_archive",
|
||||
"api",
|
||||
"bp",
|
||||
"files_ns",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.common.file_response import enforce_download_for_html
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.files import files_ns
|
||||
from models.agent import AgentDriveFileKind
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
class AgentDriveArchiveMemberQuery(BaseModel):
|
||||
tenant_id: str = Field(..., description="Tenant ID")
|
||||
agent_id: str = Field(..., description="Agent ID")
|
||||
key: str = Field(..., description="Virtual drive key")
|
||||
archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind")
|
||||
archive_file_id: str = Field(..., description="Archive file id")
|
||||
member_path: str = Field(..., description="Zip member path")
|
||||
timestamp: str = Field(..., description="Unix timestamp")
|
||||
nonce: str = Field(..., description="Random nonce")
|
||||
sign: str = Field(..., description="HMAC signature")
|
||||
as_attachment: bool = Field(default=False, description="Download as attachment")
|
||||
|
||||
|
||||
register_schema_models(files_ns, AgentDriveArchiveMemberQuery)
|
||||
|
||||
|
||||
@files_ns.route("/agent-drive/archive-member")
|
||||
class AgentDriveArchiveMemberApi(Resource):
|
||||
@files_ns.doc("get_agent_drive_archive_member")
|
||||
@files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters")
|
||||
def get(self):
|
||||
args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True))
|
||||
if not AgentDriveService.verify_archive_member_signature(
|
||||
tenant_id=args.tenant_id,
|
||||
agent_id=args.agent_id,
|
||||
key=args.key,
|
||||
archive_file_kind=args.archive_file_kind,
|
||||
archive_file_id=args.archive_file_id,
|
||||
member_path=args.member_path,
|
||||
timestamp=args.timestamp,
|
||||
nonce=args.nonce,
|
||||
sign=args.sign,
|
||||
):
|
||||
raise Forbidden("Invalid request.")
|
||||
try:
|
||||
payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request(
|
||||
tenant_id=args.tenant_id,
|
||||
agent_id=args.agent_id,
|
||||
key=args.key,
|
||||
archive_file_kind=args.archive_file_kind,
|
||||
archive_file_id=args.archive_file_id,
|
||||
member_path=args.member_path,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
raise NotFound(exc.message) from exc
|
||||
|
||||
response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
|
||||
response.headers["Content-Length"] = str(len(payload))
|
||||
if args.as_attachment and filename:
|
||||
encoded_filename = quote(filename)
|
||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
|
||||
return response
|
||||
@@ -565,7 +565,7 @@ class DatasetApi(DatasetApiResource):
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
data = _dump_service_dataset_detail(dataset)
|
||||
@@ -819,7 +819,7 @@ class DocumentStatusApi(DatasetApiResource):
|
||||
|
||||
# Check user's permission
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata = MetadataService.create_metadata(db.session(), dataset_id_str, metadata_args)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 201
|
||||
@@ -157,7 +157,7 @@ class DatasetMetadataServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata = MetadataService.update_metadata_name(db.session(), dataset_id_str, metadata_id_str, payload.name)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 200
|
||||
@@ -192,7 +192,7 @@ class DatasetMetadataServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
|
||||
return "", 204
|
||||
@@ -260,7 +260,7 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
@@ -306,7 +306,7 @@ class DocumentMetadataEditServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata_args = MetadataOperationData.model_validate(service_api_ns.payload or {})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from fields.base import ResponseModel
|
||||
from libs.helper import AppIconUrlField
|
||||
from models.account import TenantStatus
|
||||
from models.model import App, EndUser, Site
|
||||
from services.feature_service import FeatureService
|
||||
from services.feature_service import FeatureModel, FeatureService
|
||||
|
||||
|
||||
class AppSiteModelConfigResponse(ResponseModel):
|
||||
@@ -38,6 +38,7 @@ class AppSiteResponse(ResponseModel):
|
||||
description: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
default_language: str | None = None
|
||||
prompt_public: bool | None = None
|
||||
@@ -84,6 +85,7 @@ class AppSiteApi(WebApiResource):
|
||||
"description": fields.String,
|
||||
"copyright": fields.String,
|
||||
"privacy_policy": fields.String,
|
||||
"input_placeholder": fields.String,
|
||||
"custom_disclaimer": fields.String,
|
||||
"default_language": fields.String,
|
||||
"prompt_public": fields.Boolean,
|
||||
@@ -127,9 +129,15 @@ class AppSiteApi(WebApiResource):
|
||||
if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE:
|
||||
raise Forbidden()
|
||||
|
||||
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
|
||||
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
|
||||
|
||||
return AppSiteInfo(app_model.tenant, app_model, site, end_user.id, can_replace_logo)
|
||||
return AppSiteInfo(
|
||||
app_model.tenant,
|
||||
app_model,
|
||||
serialize_runtime_site(site, features),
|
||||
end_user.id,
|
||||
features.can_replace_logo,
|
||||
)
|
||||
|
||||
|
||||
class AppSiteInfo:
|
||||
@@ -164,7 +172,23 @@ def serialize_site(site: Site) -> dict[str, Any]:
|
||||
return cast(dict[str, Any], marshal(site, AppSiteApi.site_fields))
|
||||
|
||||
|
||||
def serialize_runtime_site(site: Site, features: FeatureModel) -> dict[str, Any]:
|
||||
site_payload = serialize_site(site)
|
||||
if not features.billing.enabled or features.webapp_copyright_enabled:
|
||||
return site_payload
|
||||
|
||||
site_payload["copyright"] = None
|
||||
site_payload["input_placeholder"] = None
|
||||
return site_payload
|
||||
|
||||
|
||||
def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]:
|
||||
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
|
||||
app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, can_replace_logo)
|
||||
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
|
||||
app_site_info = AppSiteInfo(
|
||||
app_model.tenant,
|
||||
app_model,
|
||||
serialize_runtime_site(site, features),
|
||||
end_user_id,
|
||||
features.can_replace_logo,
|
||||
)
|
||||
return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields))
|
||||
|
||||
@@ -16,7 +16,7 @@ from collections.abc import Generator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, current_app
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import and_, or_, select
|
||||
|
||||
from clients.agent_backend import AgentBackendRunEventAdapter
|
||||
from clients.agent_backend.factory import create_agent_backend_run_client
|
||||
@@ -42,7 +42,15 @@ from core.app.llm.model_access import build_dify_model_access
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App, EndUser, Message
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigSnapshot,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
@@ -73,10 +81,15 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
inputs = args["inputs"]
|
||||
|
||||
# Resolve the bound roster Agent + its current Agent Soul snapshot.
|
||||
agent, snapshot, agent_soul = self._resolve_agent(app_model)
|
||||
agent, agent_config_id, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type=args.get("draft_type"),
|
||||
user=user,
|
||||
)
|
||||
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
|
||||
invoke_from=invoke_from,
|
||||
snapshot_id=snapshot.id,
|
||||
snapshot_id=agent_config_id,
|
||||
)
|
||||
|
||||
conversation = None
|
||||
@@ -123,7 +136,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
call_depth=0,
|
||||
trace_manager=trace_manager,
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=snapshot.id,
|
||||
agent_config_snapshot_id=agent_config_id,
|
||||
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
|
||||
)
|
||||
|
||||
@@ -179,7 +192,12 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
persisted to the conversation. Live streaming to a reconnected client is
|
||||
out of scope here — the message is persisted and can be re-fetched.
|
||||
"""
|
||||
agent, snapshot, agent_soul = self._resolve_agent(app_model)
|
||||
agent, agent_config_id, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type="draft",
|
||||
user=user,
|
||||
)
|
||||
conversation = ConversationService.get_conversation(
|
||||
app_model=app_model, conversation_id=conversation_id, user=user
|
||||
)
|
||||
@@ -226,7 +244,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
call_depth=0,
|
||||
trace_manager=trace_manager,
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=snapshot.id,
|
||||
agent_config_snapshot_id=agent_config_id,
|
||||
)
|
||||
|
||||
conversation, message = self._init_generate_records(application_generate_entity, conversation)
|
||||
@@ -421,50 +439,135 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
|
||||
return False, query
|
||||
|
||||
def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
def _resolve_agent(
|
||||
self,
|
||||
app_model: App,
|
||||
*,
|
||||
invoke_from: InvokeFrom,
|
||||
draft_type: Any,
|
||||
user: Account | EndUser,
|
||||
) -> tuple[Agent, str, AgentSoulConfig]:
|
||||
agent = db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.app_id == app_model.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
or_(
|
||||
and_(
|
||||
Agent.app_id == app_model.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
),
|
||||
Agent.backing_app_id == app_model.id,
|
||||
),
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
return self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
draft = self._resolve_debug_draft(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent=agent,
|
||||
draft_type=draft_type,
|
||||
account_id=user.id if isinstance(user, Account) else None,
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
return agent, draft.id, agent_soul
|
||||
_, snapshot, agent_soul = self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
return agent, snapshot.id, agent_soul
|
||||
|
||||
@staticmethod
|
||||
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
|
||||
"""Return the session scope snapshot id for Agent App runtime state.
|
||||
|
||||
Console preview/debug chat is an editing workspace: saving Agent Soul
|
||||
creates replacement snapshots, but the user expects the same preview
|
||||
conversation to keep context while trying prompt changes. Use a stable
|
||||
NULL snapshot scope for debugger runs so each turn can use the latest
|
||||
Agent Soul while reusing the conversation history. Published/web/API
|
||||
runs keep snapshot-scoped sessions for reproducible runtime state.
|
||||
Console preview/debug chat uses a stable Agent draft row id; build mode
|
||||
uses the current user's build-draft row id. Published/web/API runs use
|
||||
immutable published snapshot ids. This keeps runtime session continuity
|
||||
inside one editable surface without mixing draft/build/published state.
|
||||
"""
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
return None
|
||||
return snapshot_id
|
||||
|
||||
@staticmethod
|
||||
def _resolve_debug_draft(
|
||||
*, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None
|
||||
) -> AgentConfigDraft:
|
||||
effective_draft_type = (
|
||||
AgentConfigDraftType.DEBUG_BUILD
|
||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD.value
|
||||
else AgentConfigDraftType.DRAFT
|
||||
)
|
||||
stmt = select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent.id,
|
||||
AgentConfigDraft.draft_type == effective_draft_type,
|
||||
)
|
||||
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
if not account_id:
|
||||
raise AgentAppGeneratorError("Build draft requires an account user")
|
||||
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
|
||||
else:
|
||||
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
|
||||
draft = db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
|
||||
if draft is not None:
|
||||
return draft
|
||||
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
raise AgentAppGeneratorError("Agent build draft not found")
|
||||
_, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id=snapshot.id,
|
||||
config_snapshot=agent_soul,
|
||||
created_by=agent.created_by,
|
||||
updated_by=agent.updated_by,
|
||||
)
|
||||
db.session.add(draft)
|
||||
db.session.flush()
|
||||
return draft
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_by_id(
|
||||
*, tenant_id: str, agent_id: str, snapshot_id: str | None
|
||||
) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
) -> tuple[Agent, AgentConfigSnapshot | AgentConfigDraft, AgentSoulConfig]:
|
||||
agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id))
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent not found")
|
||||
if not snapshot_id:
|
||||
raise AgentAppGeneratorError("Agent has no published version")
|
||||
snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id))
|
||||
if snapshot is None:
|
||||
snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot).where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
AgentConfigSnapshot.id == snapshot_id,
|
||||
)
|
||||
)
|
||||
if snapshot is not None:
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return agent, snapshot, agent_soul
|
||||
draft = db.session.scalar(
|
||||
select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent_id,
|
||||
AgentConfigDraft.id == snapshot_id,
|
||||
)
|
||||
)
|
||||
if draft is None:
|
||||
raise AgentAppGeneratorError("Agent published version not found")
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return agent, snapshot, agent_soul
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
return agent, draft, agent_soul
|
||||
|
||||
|
||||
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
|
||||
|
||||
@@ -22,7 +22,10 @@ from core.entities.provider_entities import (
|
||||
SystemConfigurationStatus,
|
||||
)
|
||||
from core.helper import encrypter
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
|
||||
from core.helper.model_provider_cache import (
|
||||
ProviderCredentialsCache,
|
||||
ProviderCredentialsCacheType,
|
||||
)
|
||||
from core.plugin.impl.model_runtime_factory import create_model_type_instance, create_plugin_model_assembly
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import (
|
||||
@@ -473,6 +476,39 @@ class ProviderConfiguration(BaseModel):
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
return provider_names
|
||||
|
||||
def _invalidate_provider_configuration_cache(
|
||||
self,
|
||||
*,
|
||||
provider_models: bool = False,
|
||||
preferred_model_providers: bool = False,
|
||||
provider_model_settings: bool = False,
|
||||
provider_model_credentials: bool = False,
|
||||
provider_credentials: bool = False,
|
||||
provider_load_balancing_configs: bool = False,
|
||||
) -> None:
|
||||
"""Invalidate tenant-scoped provider snapshots after committing configuration writes."""
|
||||
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
|
||||
|
||||
sources: list[ProviderConfigurationCacheSource] = []
|
||||
if provider_models:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODELS)
|
||||
if preferred_model_providers:
|
||||
sources.append(ProviderConfigurationCacheSource.PREFERRED_MODEL_PROVIDERS)
|
||||
if provider_model_settings:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODEL_SETTINGS)
|
||||
if provider_model_credentials:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODEL_CREDENTIALS)
|
||||
if provider_credentials:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS)
|
||||
if provider_load_balancing_configs:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS)
|
||||
|
||||
if not sources:
|
||||
logger.warning("No provider configuration cache source selected for invalidation")
|
||||
return
|
||||
|
||||
ProviderManager.invalidate_configurations_cache(self.tenant_id, sources=sources)
|
||||
|
||||
def create_provider_credential(self, credentials: dict[str, Any], credential_name: str | None):
|
||||
"""
|
||||
Add custom provider credentials.
|
||||
@@ -489,6 +525,7 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
credentials = self.validate_provider_credentials(credentials=credentials)
|
||||
|
||||
preferred_model_providers_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_record = self._get_provider_record(session)
|
||||
try:
|
||||
@@ -518,7 +555,9 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.CUSTOM, session=session
|
||||
)
|
||||
else:
|
||||
provider_record.is_valid = True
|
||||
|
||||
@@ -533,12 +572,18 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.CUSTOM, session=session
|
||||
)
|
||||
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
preferred_model_providers=preferred_model_providers_changed,
|
||||
provider_credentials=True,
|
||||
)
|
||||
|
||||
def update_provider_credential(
|
||||
self,
|
||||
@@ -562,6 +607,7 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
credentials = self.validate_provider_credentials(credentials=credentials, credential_id=credential_id)
|
||||
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_record = self._get_provider_record(session)
|
||||
stmt = select(ProviderCredential).where(
|
||||
@@ -588,7 +634,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
self._update_load_balancing_configs_with_credential(
|
||||
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
|
||||
credential_id=credential_id,
|
||||
credential_record=credential_record,
|
||||
credential_source=CredentialSourceType.PROVIDER,
|
||||
@@ -597,6 +643,10 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def _update_load_balancing_configs_with_credential(
|
||||
self,
|
||||
@@ -604,7 +654,7 @@ class ProviderConfiguration(BaseModel):
|
||||
credential_record: ProviderCredential | ProviderModelCredential,
|
||||
credential_source: str,
|
||||
session: Session,
|
||||
):
|
||||
) -> bool:
|
||||
"""
|
||||
Update load balancing configurations that reference the given credential_id.
|
||||
|
||||
@@ -625,7 +675,7 @@ class ProviderConfiguration(BaseModel):
|
||||
load_balancing_configs = session.execute(stmt).scalars().all()
|
||||
|
||||
if not load_balancing_configs:
|
||||
return
|
||||
return False
|
||||
|
||||
# Update each load balancing config with the new credentials
|
||||
for lb_config in load_balancing_configs:
|
||||
@@ -643,6 +693,7 @@ class ProviderConfiguration(BaseModel):
|
||||
lb_credentials_cache.delete()
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
def delete_provider_credential(self, credential_id: str):
|
||||
"""
|
||||
@@ -651,6 +702,8 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
preferred_model_providers_changed = False
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderCredential).where(
|
||||
ProviderCredential.id == credential_id,
|
||||
@@ -671,6 +724,7 @@ class ProviderConfiguration(BaseModel):
|
||||
LoadBalancingModelConfig.credential_source_type == CredentialSourceType.PROVIDER,
|
||||
)
|
||||
lb_configs_using_credential = session.execute(lb_stmt).scalars().all()
|
||||
load_balancing_configs_changed = bool(lb_configs_using_credential)
|
||||
try:
|
||||
for lb_config in lb_configs_using_credential:
|
||||
lb_credentials_cache = ProviderCredentialsCache(
|
||||
@@ -703,7 +757,9 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.SYSTEM, session=session)
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.SYSTEM, session=session
|
||||
)
|
||||
elif provider_record and provider_record.credential_id == credential_id:
|
||||
provider_record.credential_id = None
|
||||
provider_record.updated_at = naive_utc_now()
|
||||
@@ -714,12 +770,19 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.SYSTEM, session=session)
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.SYSTEM, session=session
|
||||
)
|
||||
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
preferred_model_providers=preferred_model_providers_changed,
|
||||
provider_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def switch_active_provider_credential(self, credential_id: str):
|
||||
"""
|
||||
@@ -728,6 +791,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
preferred_model_providers_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderCredential).where(
|
||||
ProviderCredential.id == credential_id,
|
||||
@@ -753,10 +817,14 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
self.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
ProviderType.CUSTOM, session=session
|
||||
)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
if preferred_model_providers_changed:
|
||||
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
|
||||
|
||||
def _get_custom_model_record(
|
||||
self,
|
||||
@@ -1017,6 +1085,10 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
)
|
||||
|
||||
def update_custom_model_credential(
|
||||
self,
|
||||
@@ -1053,6 +1125,7 @@ class ProviderConfiguration(BaseModel):
|
||||
credential_id=credential_id,
|
||||
)
|
||||
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session)
|
||||
|
||||
@@ -1082,7 +1155,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
self._update_load_balancing_configs_with_credential(
|
||||
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
|
||||
credential_id=credential_id,
|
||||
credential_record=credential_record,
|
||||
credential_source=CredentialSourceType.CUSTOM_MODEL,
|
||||
@@ -1091,6 +1164,11 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def delete_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@@ -1099,6 +1177,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderModelCredential).where(
|
||||
ProviderModelCredential.id == credential_id,
|
||||
@@ -1118,6 +1197,7 @@ class ProviderConfiguration(BaseModel):
|
||||
LoadBalancingModelConfig.credential_source_type == CredentialSourceType.CUSTOM_MODEL,
|
||||
)
|
||||
lb_configs_using_credential = session.execute(lb_stmt).scalars().all()
|
||||
load_balancing_configs_changed = bool(lb_configs_using_credential)
|
||||
|
||||
try:
|
||||
for lb_config in lb_configs_using_credential:
|
||||
@@ -1161,6 +1241,11 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def add_model_credential_to_model(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@@ -1213,6 +1298,7 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
session.add(provider_model_record)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def switch_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@@ -1251,6 +1337,7 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.MODEL,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def delete_custom_model(self, model_type: ModelType, model: str):
|
||||
"""
|
||||
@@ -1259,6 +1346,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:param model: model name
|
||||
:return:
|
||||
"""
|
||||
provider_models_changed = False
|
||||
with Session(db.engine) as session:
|
||||
# get provider model
|
||||
provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session)
|
||||
@@ -1267,6 +1355,7 @@ class ProviderConfiguration(BaseModel):
|
||||
if provider_model_record:
|
||||
session.delete(provider_model_record)
|
||||
session.commit()
|
||||
provider_models_changed = True
|
||||
|
||||
provider_model_credentials_cache = ProviderCredentialsCache(
|
||||
tenant_id=self.tenant_id,
|
||||
@@ -1275,6 +1364,8 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
|
||||
provider_model_credentials_cache.delete()
|
||||
if provider_models_changed:
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def _get_provider_model_setting(
|
||||
self, model_type: ModelType, model: str, session: Session
|
||||
@@ -1314,6 +1405,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1340,6 +1432,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1392,6 +1485,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1419,6 +1513,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1454,19 +1549,19 @@ class ProviderConfiguration(BaseModel):
|
||||
credentials=credentials or {},
|
||||
)
|
||||
|
||||
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None):
|
||||
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None) -> bool:
|
||||
"""
|
||||
Switch preferred provider type.
|
||||
:param provider_type:
|
||||
:return:
|
||||
"""
|
||||
if provider_type == self.preferred_provider_type:
|
||||
return
|
||||
return False
|
||||
|
||||
if provider_type == ProviderType.SYSTEM and not self.system_configuration.enabled:
|
||||
return
|
||||
return False
|
||||
|
||||
def _switch(s: Session):
|
||||
def _switch(s: Session) -> bool:
|
||||
stmt = select(TenantPreferredModelProvider).where(
|
||||
TenantPreferredModelProvider.tenant_id == self.tenant_id,
|
||||
TenantPreferredModelProvider.provider_name.in_(self._get_provider_names()),
|
||||
@@ -1483,12 +1578,16 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
s.add(preferred_model_provider)
|
||||
s.commit()
|
||||
return True
|
||||
|
||||
if session:
|
||||
return _switch(session)
|
||||
else:
|
||||
with Session(db.engine) as session:
|
||||
return _switch(session)
|
||||
changed = _switch(session)
|
||||
if changed:
|
||||
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
|
||||
return changed
|
||||
|
||||
def extract_secret_variables(self, credential_form_schemas: list[CredentialFormSchema]) -> list[str]:
|
||||
"""
|
||||
|
||||
+620
-55
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import StrEnum
|
||||
from json import JSONDecodeError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
@@ -41,6 +45,7 @@ from graphon.model_runtime.entities.provider_entities import (
|
||||
ProviderEntity,
|
||||
)
|
||||
from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
|
||||
from models.enums import CredentialSourceType
|
||||
from models.provider import (
|
||||
LoadBalancingModelConfig,
|
||||
Provider,
|
||||
@@ -59,7 +64,494 @@ if TYPE_CHECKING:
|
||||
from graphon.model_runtime.protocols.runtime import ModelRuntime
|
||||
from models.account import Account
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_credentials_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any])
|
||||
_PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS = 300
|
||||
_PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS = 360
|
||||
_PROVIDER_CONFIGURATION_CACHE_VERSION_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:version"
|
||||
_PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:v:{version}"
|
||||
|
||||
|
||||
class ProviderConfigurationCacheSource(StrEnum):
|
||||
PROVIDER_MODELS = "provider_models"
|
||||
PREFERRED_MODEL_PROVIDERS = "preferred_model_providers"
|
||||
PROVIDER_MODEL_SETTINGS = "provider_model_settings"
|
||||
PROVIDER_MODEL_CREDENTIALS = "provider_model_credentials"
|
||||
PROVIDER_CREDENTIALS = "provider_credentials"
|
||||
PROVIDER_LOAD_BALANCING_CONFIGS = "provider_load_balancing_configs"
|
||||
|
||||
|
||||
_PROVIDER_CONFIGURATION_SOURCES = tuple(ProviderConfigurationCacheSource)
|
||||
|
||||
|
||||
class _CacheEntry(Protocol):
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> Self: ...
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderConfigurationCacheSourceSpec[T: _CacheEntry]:
|
||||
name: ProviderConfigurationCacheSource
|
||||
entry_cls: type[T]
|
||||
load_records: Callable[[str], list[T]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
credential_id: str | None
|
||||
credential_name: str | None
|
||||
encrypted_config: str | None
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModel) -> _ProviderModelCacheEntry:
|
||||
credential = record.__dict__.get("credential")
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
credential_id=record.credential_id,
|
||||
credential_name=credential.credential_name if credential else None,
|
||||
encrypted_config=credential.encrypted_config if credential else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
credential_id=row.get("credential_id"),
|
||||
credential_name=row.get("credential_name"),
|
||||
encrypted_config=row.get("encrypted_config"),
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
row = asdict(self)
|
||||
row["model_type"] = self.model_type.value
|
||||
return row
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TenantPreferredModelProviderCacheEntry:
|
||||
provider_name: str
|
||||
preferred_provider_type: ProviderType
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: TenantPreferredModelProvider) -> _TenantPreferredModelProviderCacheEntry:
|
||||
return cls(
|
||||
provider_name=record.provider_name,
|
||||
preferred_provider_type=record.preferred_provider_type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _TenantPreferredModelProviderCacheEntry:
|
||||
return cls(
|
||||
provider_name=row["provider_name"],
|
||||
preferred_provider_type=ProviderType(row["preferred_provider_type"]),
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"preferred_provider_type": self.preferred_provider_type.value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelSettingCacheEntry:
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
enabled: bool
|
||||
load_balancing_enabled: bool
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModelSetting) -> _ProviderModelSettingCacheEntry:
|
||||
return cls(
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
enabled=record.enabled,
|
||||
load_balancing_enabled=record.load_balancing_enabled,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelSettingCacheEntry:
|
||||
return cls(
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
enabled=row["enabled"],
|
||||
load_balancing_enabled=row["load_balancing_enabled"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"enabled": self.enabled,
|
||||
"load_balancing_enabled": self.load_balancing_enabled,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelCredentialCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
credential_name: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModelCredential) -> _ProviderModelCredentialCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
credential_name=record.credential_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelCredentialCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
credential_name=row["credential_name"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"credential_name": self.credential_name,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderCredentialCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
credential_name: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderCredential) -> _ProviderCredentialCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
credential_name=record.credential_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderCredentialCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
credential_name=row["credential_name"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LoadBalancingModelConfigCacheEntry:
|
||||
id: str
|
||||
tenant_id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
name: str
|
||||
encrypted_config: str | None
|
||||
credential_id: str | None
|
||||
credential_source_type: CredentialSourceType | None
|
||||
enabled: bool
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: LoadBalancingModelConfig) -> _LoadBalancingModelConfigCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
tenant_id=record.tenant_id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
name=record.name,
|
||||
encrypted_config=record.encrypted_config,
|
||||
credential_id=record.credential_id,
|
||||
credential_source_type=record.credential_source_type,
|
||||
enabled=record.enabled,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _LoadBalancingModelConfigCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
tenant_id=row["tenant_id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
name=row["name"],
|
||||
encrypted_config=row.get("encrypted_config"),
|
||||
credential_id=row.get("credential_id"),
|
||||
credential_source_type=CredentialSourceType(row["credential_source_type"])
|
||||
if row.get("credential_source_type")
|
||||
else None,
|
||||
enabled=row["enabled"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"name": self.name,
|
||||
"encrypted_config": self.encrypted_config,
|
||||
"credential_id": self.credential_id,
|
||||
"credential_source_type": self.credential_source_type.value if self.credential_source_type else None,
|
||||
"enabled": self.enabled,
|
||||
}
|
||||
|
||||
|
||||
class _ProviderConfigurationSourceCache:
|
||||
"""Redis-backed cache for tenant provider DB cache entries.
|
||||
|
||||
The assembled ``ProviderConfigurations`` object is intentionally not cached
|
||||
here because it carries request-scoped runtime bindings. Cache only the DB
|
||||
rows that are stable enough to reuse across processes, then let each
|
||||
``ProviderManager`` assemble and bind fresh runtime-aware entities.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_records[T: _CacheEntry](
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source: ProviderConfigurationCacheSource,
|
||||
entry_cls: type[T],
|
||||
) -> tuple[list[T] | None, str | None]:
|
||||
version: str | None = None
|
||||
try:
|
||||
version = cls._get_version(tenant_id=tenant_id, source=source)
|
||||
cache_key = cls._source_key(tenant_id=tenant_id, source=source, version=version)
|
||||
cached_records = redis_client.get(cache_key)
|
||||
if cached_records is None:
|
||||
return None, version
|
||||
|
||||
cached_text = cached_records.decode("utf-8") if isinstance(cached_records, bytes) else cached_records
|
||||
rows = json.loads(cached_text)
|
||||
if not isinstance(rows, list):
|
||||
return None, version
|
||||
|
||||
return [entry_cls.from_cache_row(row) for row in rows if isinstance(row, dict)], version
|
||||
except Exception:
|
||||
logger.warning("Failed to read provider configuration source cache", exc_info=True)
|
||||
return None, version
|
||||
|
||||
@classmethod
|
||||
def set_records(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source: ProviderConfigurationCacheSource,
|
||||
records: Sequence[_CacheEntry],
|
||||
expected_version: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
version = cls._get_version(tenant_id=tenant_id, source=source)
|
||||
if expected_version is not None and version != expected_version:
|
||||
return
|
||||
cache_key = cls._source_key(tenant_id=tenant_id, source=source, version=version)
|
||||
rows = [record.to_cache_row() for record in records]
|
||||
redis_client.setex(cache_key, _PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS, json.dumps(rows))
|
||||
except Exception:
|
||||
logger.warning("Failed to write provider configuration source cache", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def invalidate_tenant(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
sources: Sequence[ProviderConfigurationCacheSource] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
if sources is None:
|
||||
sources = _PROVIDER_CONFIGURATION_SOURCES
|
||||
for source in sources:
|
||||
version_key = _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY.format(tenant_id=tenant_id, source=source.value)
|
||||
redis_client.incr(version_key)
|
||||
redis_client.expire(version_key, _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
except Exception:
|
||||
logger.warning("Failed to invalidate provider configuration source cache", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def _get_version(cls, *, tenant_id: str, source: ProviderConfigurationCacheSource) -> str:
|
||||
version_key = _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY.format(tenant_id=tenant_id, source=source.value)
|
||||
version = redis_client.get(version_key)
|
||||
if version is None:
|
||||
redis_client.set(version_key, "0", ex=_PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
return "0"
|
||||
redis_client.expire(version_key, _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
return version.decode("utf-8") if isinstance(version, bytes) else str(version)
|
||||
|
||||
@staticmethod
|
||||
def _source_key(*, tenant_id: str, source: ProviderConfigurationCacheSource, version: str) -> str:
|
||||
return _PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY.format(
|
||||
tenant_id=tenant_id,
|
||||
source=source.value,
|
||||
version=version,
|
||||
)
|
||||
|
||||
|
||||
def _get_cached_or_load_records[T: _CacheEntry](
|
||||
*,
|
||||
tenant_id: str,
|
||||
cache_source: _ProviderConfigurationCacheSourceSpec[T],
|
||||
) -> list[T]:
|
||||
cached_records, cache_version = _ProviderConfigurationSourceCache.get_records(
|
||||
tenant_id=tenant_id,
|
||||
source=cache_source.name,
|
||||
entry_cls=cache_source.entry_cls,
|
||||
)
|
||||
if cached_records is not None:
|
||||
return cached_records
|
||||
|
||||
records = cache_source.load_records(tenant_id)
|
||||
_ProviderConfigurationSourceCache.set_records(
|
||||
tenant_id=tenant_id,
|
||||
source=cache_source.name,
|
||||
records=records,
|
||||
expected_version=cache_version,
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _attach_active_credentials(
|
||||
*,
|
||||
session: Any,
|
||||
records: Sequence[Provider | ProviderModel],
|
||||
credential_model_cls: type[ProviderCredential | ProviderModelCredential],
|
||||
) -> None:
|
||||
credential_ids = [record.credential_id for record in records if getattr(record, "credential_id", None)]
|
||||
if not credential_ids:
|
||||
return
|
||||
|
||||
credentials = session.scalars(select(credential_model_cls).where(credential_model_cls.id.in_(credential_ids))).all()
|
||||
credential_by_id = {credential.id: credential for credential in credentials}
|
||||
for record in records:
|
||||
if getattr(record, "credential_id", None):
|
||||
record.__dict__["credential"] = credential_by_id.get(record.credential_id)
|
||||
|
||||
|
||||
def _load_provider_model_cache_entries(tenant_id: str) -> list[_ProviderModelCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
|
||||
provider_models = list(session.scalars(stmt))
|
||||
_attach_active_credentials(
|
||||
session=session,
|
||||
records=provider_models,
|
||||
credential_model_cls=ProviderModelCredential,
|
||||
)
|
||||
return [_ProviderModelCacheEntry.from_record(provider_model) for provider_model in provider_models]
|
||||
|
||||
|
||||
def _load_preferred_model_provider_cache_entries(tenant_id: str) -> list[_TenantPreferredModelProviderCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
|
||||
return [
|
||||
_TenantPreferredModelProviderCacheEntry.from_record(preferred_model_provider)
|
||||
for preferred_model_provider in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_model_setting_cache_entries(tenant_id: str) -> list[_ProviderModelSettingCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
|
||||
return [
|
||||
_ProviderModelSettingCacheEntry.from_record(provider_model_setting)
|
||||
for provider_model_setting in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_model_credential_cache_entries(tenant_id: str) -> list[_ProviderModelCredentialCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(ProviderModelCredential)
|
||||
.where(ProviderModelCredential.tenant_id == tenant_id)
|
||||
.order_by(ProviderModelCredential.created_at.desc())
|
||||
)
|
||||
return [
|
||||
_ProviderModelCredentialCacheEntry.from_record(provider_model_credential)
|
||||
for provider_model_credential in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_credential_cache_entries(tenant_id: str) -> list[_ProviderCredentialCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(ProviderCredential)
|
||||
.where(ProviderCredential.tenant_id == tenant_id)
|
||||
.order_by(ProviderCredential.created_at.desc())
|
||||
)
|
||||
return [
|
||||
_ProviderCredentialCacheEntry.from_record(provider_credential)
|
||||
for provider_credential in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_load_balancing_config_cache_entries(tenant_id: str) -> list[_LoadBalancingModelConfigCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
|
||||
return [
|
||||
_LoadBalancingModelConfigCacheEntry.from_record(load_balancing_model_config)
|
||||
for load_balancing_model_config in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
_PROVIDER_MODELS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODELS,
|
||||
entry_cls=_ProviderModelCacheEntry,
|
||||
load_records=_load_provider_model_cache_entries,
|
||||
)
|
||||
_PREFERRED_MODEL_PROVIDERS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PREFERRED_MODEL_PROVIDERS,
|
||||
entry_cls=_TenantPreferredModelProviderCacheEntry,
|
||||
load_records=_load_preferred_model_provider_cache_entries,
|
||||
)
|
||||
_PROVIDER_MODEL_SETTINGS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODEL_SETTINGS,
|
||||
entry_cls=_ProviderModelSettingCacheEntry,
|
||||
load_records=_load_provider_model_setting_cache_entries,
|
||||
)
|
||||
_PROVIDER_MODEL_CREDENTIALS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODEL_CREDENTIALS,
|
||||
entry_cls=_ProviderModelCredentialCacheEntry,
|
||||
load_records=_load_provider_model_credential_cache_entries,
|
||||
)
|
||||
_PROVIDER_CREDENTIALS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,
|
||||
entry_cls=_ProviderCredentialCacheEntry,
|
||||
load_records=_load_provider_credential_cache_entries,
|
||||
)
|
||||
_PROVIDER_LOAD_BALANCING_CONFIGS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,
|
||||
entry_cls=_LoadBalancingModelConfigCacheEntry,
|
||||
load_records=_load_provider_load_balancing_config_cache_entries,
|
||||
)
|
||||
|
||||
|
||||
class ProviderManager:
|
||||
@@ -98,6 +590,14 @@ class ProviderManager:
|
||||
|
||||
self._configurations_cache.pop(tenant_id, None)
|
||||
|
||||
@staticmethod
|
||||
def invalidate_configurations_cache(
|
||||
tenant_id: str,
|
||||
sources: Sequence[ProviderConfigurationCacheSource] | None = None,
|
||||
) -> None:
|
||||
"""Invalidate cross-process provider configuration source cache for a tenant."""
|
||||
_ProviderConfigurationSourceCache.invalidate_tenant(tenant_id, sources=sources)
|
||||
|
||||
def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
|
||||
"""
|
||||
Get model provider configurations.
|
||||
@@ -192,6 +692,9 @@ class ProviderManager:
|
||||
# Get All provider model credentials
|
||||
provider_name_to_provider_model_credentials_dict = self._get_all_provider_model_credentials(tenant_id)
|
||||
|
||||
# Get All provider credentials
|
||||
provider_name_to_provider_credentials_dict = self._get_all_provider_credentials(tenant_id)
|
||||
|
||||
provider_configurations = ProviderConfigurations(tenant_id=tenant_id)
|
||||
|
||||
# Construct ProviderConfiguration objects for each provider
|
||||
@@ -224,7 +727,12 @@ class ProviderManager:
|
||||
|
||||
# Convert to custom configuration
|
||||
custom_configuration = self._to_custom_configuration(
|
||||
tenant_id, provider_entity, provider_records, provider_model_records, provider_model_credentials
|
||||
tenant_id,
|
||||
provider_entity,
|
||||
provider_records,
|
||||
provider_model_records,
|
||||
provider_model_credentials,
|
||||
provider_name_to_provider_credentials_dict,
|
||||
)
|
||||
|
||||
# Convert to system configuration
|
||||
@@ -448,84 +956,115 @@ class ProviderManager:
|
||||
provider_name_to_provider_records_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(Provider).where(Provider.tenant_id == tenant_id, Provider.is_valid == True)
|
||||
providers = session.scalars(stmt)
|
||||
providers = list(session.scalars(stmt))
|
||||
_attach_active_credentials(
|
||||
session=session,
|
||||
records=providers,
|
||||
credential_model_cls=ProviderCredential,
|
||||
)
|
||||
for provider in providers:
|
||||
# Use provider name with prefix after the data migration
|
||||
provider_name_to_provider_records_dict[str(ModelProviderID(provider.provider_name))].append(provider)
|
||||
return provider_name_to_provider_records_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_models(tenant_id: str) -> dict[str, list[ProviderModel]]:
|
||||
def _get_all_provider_models(tenant_id: str) -> dict[str, list[_ProviderModelCacheEntry]]:
|
||||
"""
|
||||
Get all provider model records of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_models = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODELS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_records_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
|
||||
provider_models = session.scalars(stmt)
|
||||
for provider_model in provider_models:
|
||||
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
|
||||
for provider_model in provider_models:
|
||||
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
|
||||
return provider_name_to_provider_model_records_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, TenantPreferredModelProvider]:
|
||||
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, _TenantPreferredModelProviderCacheEntry]:
|
||||
"""
|
||||
Get All preferred provider types of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_name_to_preferred_provider_type_records_dict = {}
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
|
||||
preferred_provider_types = session.scalars(stmt)
|
||||
provider_name_to_preferred_provider_type_records_dict = {
|
||||
preferred_provider_type.provider_name: preferred_provider_type
|
||||
for preferred_provider_type in preferred_provider_types
|
||||
}
|
||||
return provider_name_to_preferred_provider_type_records_dict
|
||||
preferred_provider_types = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PREFERRED_MODEL_PROVIDERS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
return {
|
||||
preferred_provider_type.provider_name: preferred_provider_type
|
||||
for preferred_provider_type in preferred_provider_types
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[ProviderModelSetting]]:
|
||||
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[_ProviderModelSettingCacheEntry]]:
|
||||
"""
|
||||
Get All provider model settings of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_model_settings = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODEL_SETTINGS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_settings_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
|
||||
provider_model_settings = session.scalars(stmt)
|
||||
for provider_model_setting in provider_model_settings:
|
||||
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
|
||||
provider_model_setting
|
||||
)
|
||||
for provider_model_setting in provider_model_settings:
|
||||
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
|
||||
provider_model_setting
|
||||
)
|
||||
return provider_name_to_provider_model_settings_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[ProviderModelCredential]]:
|
||||
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[_ProviderModelCredentialCacheEntry]]:
|
||||
"""
|
||||
Get All provider model credentials of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_model_credentials = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODEL_CREDENTIALS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_credentials_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelCredential).where(ProviderModelCredential.tenant_id == tenant_id)
|
||||
provider_model_credentials = session.scalars(stmt)
|
||||
for provider_model_credential in provider_model_credentials:
|
||||
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
|
||||
provider_model_credential
|
||||
)
|
||||
for provider_model_credential in provider_model_credentials:
|
||||
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
|
||||
provider_model_credential
|
||||
)
|
||||
return provider_name_to_provider_model_credentials_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_load_balancing_configs(tenant_id: str) -> dict[str, list[LoadBalancingModelConfig]]:
|
||||
def _get_all_provider_credentials(tenant_id: str) -> dict[str, list[_ProviderCredentialCacheEntry]]:
|
||||
"""
|
||||
Get All provider credentials of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_credentials = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_CREDENTIALS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_credentials_dict = defaultdict(list)
|
||||
for provider_credential in provider_credentials:
|
||||
provider_name_to_provider_credentials_dict[provider_credential.provider_name].append(provider_credential)
|
||||
return provider_name_to_provider_credentials_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_load_balancing_configs(
|
||||
tenant_id: str,
|
||||
) -> dict[str, list[_LoadBalancingModelConfigCacheEntry]]:
|
||||
"""
|
||||
Get All provider load balancing configs of the workspace.
|
||||
|
||||
@@ -546,14 +1085,16 @@ class ProviderManager:
|
||||
if not model_load_balancing_enabled:
|
||||
return {}
|
||||
|
||||
provider_load_balancing_configs = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_LOAD_BALANCING_CONFIGS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_load_balancing_model_configs_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
|
||||
provider_load_balancing_configs = session.scalars(stmt)
|
||||
for provider_load_balancing_config in provider_load_balancing_configs:
|
||||
provider_name_to_provider_load_balancing_model_configs_dict[
|
||||
provider_load_balancing_config.provider_name
|
||||
].append(provider_load_balancing_config)
|
||||
for provider_load_balancing_config in provider_load_balancing_configs:
|
||||
provider_name_to_provider_load_balancing_model_configs_dict[
|
||||
provider_load_balancing_config.provider_name
|
||||
].append(provider_load_balancing_config)
|
||||
|
||||
return provider_name_to_provider_load_balancing_model_configs_dict
|
||||
|
||||
@@ -722,8 +1263,9 @@ class ProviderManager:
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_records: list[Provider],
|
||||
provider_model_records: list[ProviderModel],
|
||||
provider_model_credentials: list[ProviderModelCredential],
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
provider_model_credentials: list[_ProviderModelCredentialCacheEntry],
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
) -> CustomConfiguration:
|
||||
"""
|
||||
Convert to custom configuration.
|
||||
@@ -736,7 +1278,10 @@ class ProviderManager:
|
||||
"""
|
||||
# Get custom provider configuration
|
||||
custom_provider_configuration = self._get_custom_provider_configuration(
|
||||
tenant_id, provider_entity, provider_records
|
||||
tenant_id,
|
||||
provider_entity,
|
||||
provider_records,
|
||||
provider_credentials_by_name,
|
||||
)
|
||||
|
||||
# Get custom models which have not been added to the model list yet
|
||||
@@ -758,7 +1303,11 @@ class ProviderManager:
|
||||
)
|
||||
|
||||
def _get_custom_provider_configuration(
|
||||
self, tenant_id: str, provider_entity: ProviderEntity, provider_records: list[Provider]
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_records: list[Provider],
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
) -> CustomProviderConfiguration | None:
|
||||
"""Get custom provider configuration."""
|
||||
# Find custom provider record (non-system)
|
||||
@@ -790,13 +1339,29 @@ class ProviderManager:
|
||||
credentials=provider_credentials,
|
||||
current_credential_name=custom_provider_record.credential_name,
|
||||
current_credential_id=custom_provider_record.credential_id,
|
||||
available_credentials=self.get_provider_available_credentials(
|
||||
tenant_id, custom_provider_record.provider_name
|
||||
available_credentials=self._get_provider_available_credentials_from_records(
|
||||
custom_provider_record.provider_name,
|
||||
provider_credentials_by_name,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_provider_available_credentials_from_records(
|
||||
provider_name: str,
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
) -> list[CredentialConfiguration]:
|
||||
available_credentials: list[CredentialConfiguration] = []
|
||||
for candidate_provider_name in ProviderManager._get_provider_names(provider_name):
|
||||
available_credentials.extend(
|
||||
CredentialConfiguration(credential_id=credential.id, credential_name=credential.credential_name)
|
||||
for credential in provider_credentials_by_name.get(candidate_provider_name, [])
|
||||
)
|
||||
return available_credentials
|
||||
|
||||
def _get_can_added_models(
|
||||
self, provider_model_records: list[ProviderModel], all_model_credentials: Sequence[ProviderModelCredential]
|
||||
self,
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
|
||||
) -> list[dict]:
|
||||
"""Get the custom models and credentials from enterprise version which haven't add to the model list"""
|
||||
existing_model_set = {(record.model_name, record.model_type) for record in provider_model_records}
|
||||
@@ -829,9 +1394,9 @@ class ProviderManager:
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_model_records: list[ProviderModel],
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
can_added_models: list[dict],
|
||||
all_model_credentials: Sequence[ProviderModelCredential],
|
||||
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
|
||||
) -> list[CustomModelConfiguration]:
|
||||
"""Get custom model configurations."""
|
||||
# Get model credential secret variables
|
||||
@@ -1151,8 +1716,8 @@ class ProviderManager:
|
||||
def _to_model_settings(
|
||||
self,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_model_settings: list[ProviderModelSetting] | None = None,
|
||||
load_balancing_model_configs: list[LoadBalancingModelConfig] | None = None,
|
||||
provider_model_settings: list[_ProviderModelSettingCacheEntry] | None = None,
|
||||
load_balancing_model_configs: list[_LoadBalancingModelConfigCacheEntry] | None = None,
|
||||
) -> list[ModelSettings]:
|
||||
"""
|
||||
Convert to model settings.
|
||||
|
||||
@@ -31,6 +31,7 @@ from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, Wo
|
||||
from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent
|
||||
from graphon.nodes.base.node import Node
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
|
||||
from services.agent.prompt_mentions import extract_workflow_node_output_selectors
|
||||
|
||||
from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason
|
||||
from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
|
||||
@@ -688,5 +689,20 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
node_id: str,
|
||||
node_data: DifyAgentNodeData,
|
||||
) -> Mapping[str, Sequence[str]]:
|
||||
del graph_config, node_id, node_data
|
||||
return {}
|
||||
"""Reuse frontend workflow-marker parsing for graph variable loading.
|
||||
|
||||
This follows the same marker parser used by publish sync and runtime
|
||||
request building, including reserved-prefix exclusion.
|
||||
"""
|
||||
del graph_config
|
||||
|
||||
agent_task = (
|
||||
node_data.get("agent_task") if isinstance(node_data, Mapping) else getattr(node_data, "agent_task", None)
|
||||
)
|
||||
if not isinstance(agent_task, str):
|
||||
return {}
|
||||
|
||||
return {
|
||||
f"{node_id}.{'.'.join(selector)}": list(selector)
|
||||
for selector in extract_workflow_node_output_selectors(agent_task)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
|
||||
|
||||
SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
|
||||
{
|
||||
@@ -48,9 +49,7 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
)
|
||||
|
||||
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
|
||||
reserved_status["knowledge"] = (
|
||||
"supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured"
|
||||
)
|
||||
reserved_status["knowledge"] = "supported_by_knowledge_layer" if agent_soul.knowledge.sets else "not_configured"
|
||||
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
|
||||
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
|
||||
reserved_status["env"] = "supported_by_shell_bootstrap"
|
||||
@@ -66,14 +65,14 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
|
||||
|
||||
def list_configured_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
|
||||
"""Return the normalized knowledge dataset ids that can produce a runtime layer.
|
||||
"""Return normalized dataset ids selected by Agent v2 knowledge sets.
|
||||
|
||||
``build_runtime_feature_manifest()`` and ``build_knowledge_layer_config()``
|
||||
must stay aligned: both decide knowledge support from this effective,
|
||||
non-blank dataset-id set rather than from raw
|
||||
``agent_soul.knowledge.datasets`` entries.
|
||||
stay aligned on the set-based contract: DTO validation rejects blank dataset
|
||||
ids before runtime, so this helper only flattens configured set datasets for
|
||||
metadata/diagnostic surfaces that still need a dataset-id summary.
|
||||
"""
|
||||
return [dataset_id for dataset in agent_soul.knowledge.datasets if (dataset_id := (dataset.id or "").strip())]
|
||||
return list_agent_soul_knowledge_dataset_ids(agent_soul)
|
||||
|
||||
|
||||
def _get_nested(value: dict[str, Any], path: str) -> Any:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, Protocol, assert_never, cast
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.agent_stub.protocol import AgentStubFileMapping
|
||||
from dify_agent.layers.ask_human import DifyAskHumanLayerConfig
|
||||
from dify_agent.layers.drive import (
|
||||
DifyDriveLayerConfig,
|
||||
@@ -15,7 +17,16 @@ from dify_agent.layers.execution_context import (
|
||||
DifyExecutionContextLayerConfig,
|
||||
DifyExecutionContextUserFrom,
|
||||
)
|
||||
from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig, DifyKnowledgeRetrievalConfig
|
||||
from dify_agent.layers.knowledge import (
|
||||
DifyKnowledgeBaseLayerConfig,
|
||||
DifyKnowledgeDatasetConfig,
|
||||
DifyKnowledgeMetadataFilteringConfig,
|
||||
DifyKnowledgeModelConfig,
|
||||
DifyKnowledgeQueryConfig,
|
||||
DifyKnowledgeRerankingModelConfig,
|
||||
DifyKnowledgeRetrievalConfig,
|
||||
DifyKnowledgeSetConfig,
|
||||
)
|
||||
from dify_agent.layers.shell import (
|
||||
DifyShellCliToolConfig,
|
||||
DifyShellEnvVarConfig,
|
||||
@@ -24,7 +35,7 @@ from dify_agent.layers.shell import (
|
||||
DifyShellSecretRefConfig,
|
||||
)
|
||||
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendModelConfig,
|
||||
@@ -36,11 +47,13 @@ from clients.agent_backend import (
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
from graphon.file import FileTransferMethod
|
||||
from graphon.file import File, FileTransferMethod
|
||||
from graphon.variables.segments import Segment
|
||||
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
|
||||
from models.agent_config_entities import (
|
||||
AgentKnowledgeQueryConfig,
|
||||
AgentKnowledgeMetadataFilteringConfig,
|
||||
AgentKnowledgeModelConfig,
|
||||
AgentKnowledgeRetrievalConfig,
|
||||
AgentSoulConfig,
|
||||
DeclaredArrayItem,
|
||||
DeclaredOutputChildConfig,
|
||||
@@ -58,13 +71,16 @@ from services.agent.prompt_mentions import (
|
||||
build_node_job_mention_resolver,
|
||||
build_soul_mention_resolver,
|
||||
expand_prompt_mentions,
|
||||
extract_workflow_node_output_selectors,
|
||||
normalize_previous_node_output_selector,
|
||||
parse_prompt_mentions,
|
||||
workflow_previous_node_output_refs_from_selectors,
|
||||
)
|
||||
from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref
|
||||
|
||||
from .output_failure_orchestrator import retry_idempotency_key
|
||||
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
|
||||
from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids
|
||||
from .runtime_feature_manifest import build_runtime_feature_manifest
|
||||
|
||||
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
|
||||
_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation")
|
||||
@@ -74,6 +90,13 @@ _DANGEROUS_ACK_KEYS = (
|
||||
"risk_accepted",
|
||||
"approved",
|
||||
)
|
||||
type AgentStubFileTransferMethod = Literal["local_file", "tool_file", "datasource_file", "remote_url"]
|
||||
_AGENT_STUB_FILE_TRANSFER_METHODS: Mapping[FileTransferMethod, AgentStubFileTransferMethod] = {
|
||||
FileTransferMethod.LOCAL_FILE: "local_file",
|
||||
FileTransferMethod.TOOL_FILE: "tool_file",
|
||||
FileTransferMethod.DATASOURCE_FILE: "datasource_file",
|
||||
FileTransferMethod.REMOTE_URL: "remote_url",
|
||||
}
|
||||
|
||||
|
||||
class WorkflowAgentRuntimeRequestBuildError(ValueError):
|
||||
@@ -126,6 +149,9 @@ class WorkflowAgentRuntimeRequest:
|
||||
class WorkflowAgentRuntimeRequestBuilder:
|
||||
"""Build public Dify Agent run requests from workflow Agent v2 runtime state."""
|
||||
|
||||
_WORKFLOW_USER_PROMPT_FALLBACK = "Use the current workflow context."
|
||||
_WORKFLOW_JOB_PROMPT_FALLBACK = "Use the workflow user prompt for this run."
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -147,15 +173,13 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
)
|
||||
|
||||
metadata = self._build_metadata(context, agent_soul, node_job)
|
||||
workflow_context_prompt = self._build_workflow_context_prompt(context, node_job)
|
||||
# ENG-616: expand slash-menu mention tokens into model-readable names.
|
||||
# node_output mentions expand to their reference name only — the value
|
||||
# stays in the Workflow context block (user_prompt) below.
|
||||
workflow_job_prompt = (
|
||||
expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
|
||||
or "Run this workflow Agent Node for the current run."
|
||||
effective_node_job = node_job.model_copy(
|
||||
update={"previous_node_output_refs": self._effective_previous_node_output_refs(node_job)}
|
||||
)
|
||||
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
|
||||
workflow_task_prompt = self._build_workflow_task_prompt(context, effective_node_job)
|
||||
workflow_context_prompt = self._build_workflow_context_prompt(context, effective_node_job)
|
||||
workflow_job_prompt = workflow_task_prompt or self._WORKFLOW_JOB_PROMPT_FALLBACK
|
||||
user_prompt = workflow_context_prompt or self._WORKFLOW_USER_PROMPT_FALLBACK
|
||||
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
|
||||
try:
|
||||
tools_layer = self._plugin_tools_builder.build(
|
||||
@@ -310,15 +334,19 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
context: WorkflowAgentRuntimeBuildContext,
|
||||
node_job: WorkflowNodeJobConfig,
|
||||
) -> str:
|
||||
lines = ["Workflow context loaded for this run:"]
|
||||
lines: list[str] = []
|
||||
query = get_system_text(context.variable_pool, SystemVariableKey.QUERY)
|
||||
if query:
|
||||
lines.append(f"- User query: {query}")
|
||||
|
||||
resolved_outputs = self._resolve_previous_node_outputs(
|
||||
context.variable_pool,
|
||||
node_job.previous_node_output_refs,
|
||||
)
|
||||
if not query and not resolved_outputs:
|
||||
return ""
|
||||
|
||||
lines.append("Workflow context loaded for this run:")
|
||||
if query:
|
||||
lines.append(f"- User query: {query}")
|
||||
|
||||
if resolved_outputs:
|
||||
lines.append("- Previous node outputs:")
|
||||
for item in resolved_outputs:
|
||||
@@ -327,6 +355,31 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _build_workflow_task_prompt(
|
||||
self,
|
||||
context: WorkflowAgentRuntimeBuildContext,
|
||||
node_job: WorkflowNodeJobConfig,
|
||||
) -> str:
|
||||
del context
|
||||
return expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
|
||||
|
||||
def _effective_previous_node_output_refs(
|
||||
self,
|
||||
node_job: WorkflowNodeJobConfig,
|
||||
) -> list[WorkflowPreviousNodeOutputRef]:
|
||||
"""Derive effective refs from the current frontend task markers.
|
||||
|
||||
The task text is the source of truth for previous-node context. When
|
||||
the prompt has no frontend markers, stale persisted refs are discarded
|
||||
instead of being carried forward into workflow context.
|
||||
"""
|
||||
if not node_job.workflow_prompt:
|
||||
return list(node_job.previous_node_output_refs)
|
||||
|
||||
return workflow_previous_node_output_refs_from_selectors(
|
||||
extract_workflow_node_output_selectors(node_job.workflow_prompt)
|
||||
)
|
||||
|
||||
def _resolve_previous_node_outputs(
|
||||
self,
|
||||
variable_pool: VariablePoolReader,
|
||||
@@ -334,7 +387,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
) -> list[dict[str, Any]]:
|
||||
resolved: list[dict[str, Any]] = []
|
||||
for ref in refs:
|
||||
selector = self._selector_from_ref(ref)
|
||||
selector = normalize_previous_node_output_selector(ref)
|
||||
if not selector:
|
||||
raise WorkflowAgentRuntimeRequestBuildError(
|
||||
"invalid_previous_node_output_ref",
|
||||
@@ -355,25 +408,73 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
)
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> list[str] | None:
|
||||
for key in ("selector", "variable_selector", "value_selector"):
|
||||
value = ref.get(key)
|
||||
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||
return value
|
||||
node_id = ref.get("node_id")
|
||||
output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key")
|
||||
if isinstance(node_id, str) and isinstance(output_name, str):
|
||||
return [node_id, output_name]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _summarize_value(value: Any) -> str:
|
||||
prompt_payload, used_download_mapping = WorkflowAgentRuntimeRequestBuilder._resolve_prompt_payload_value(value)
|
||||
if used_download_mapping:
|
||||
return json.dumps(prompt_payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
text = str(value)
|
||||
if len(text) > 2000:
|
||||
return text[:2000] + "...[truncated]"
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _resolve_prompt_payload_value(cls, value: Any) -> tuple[Any, bool]:
|
||||
# File-valued workflow context must surface as Agent Stub download
|
||||
# mappings so the model can materialize those inputs with
|
||||
# `dify-agent file download --mapping ...` inside the sandbox.
|
||||
download_mapping = cls._agent_stub_download_mapping(value)
|
||||
if download_mapping is not None:
|
||||
return download_mapping, True
|
||||
|
||||
if isinstance(value, list | tuple):
|
||||
changed = False
|
||||
resolved_items: list[Any] = []
|
||||
for item in value:
|
||||
resolved_item, item_changed = cls._resolve_prompt_payload_value(item)
|
||||
resolved_items.append(resolved_item)
|
||||
changed = changed or item_changed
|
||||
return resolved_items, changed
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
changed = False
|
||||
resolved_items_by_key: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
resolved_item, item_changed = cls._resolve_prompt_payload_value(item)
|
||||
resolved_items_by_key[str(key)] = resolved_item
|
||||
changed = changed or item_changed
|
||||
return resolved_items_by_key, changed
|
||||
|
||||
return value, False
|
||||
|
||||
@staticmethod
|
||||
def _agent_stub_download_mapping(value: Any) -> dict[str, Any] | None:
|
||||
try:
|
||||
if isinstance(value, File):
|
||||
transfer_method = _AGENT_STUB_FILE_TRANSFER_METHODS.get(value.transfer_method)
|
||||
if transfer_method is None:
|
||||
return None
|
||||
if value.transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
url = value.remote_url
|
||||
if not isinstance(url, str) or not url:
|
||||
return None
|
||||
mapping = AgentStubFileMapping(transfer_method=transfer_method, url=url)
|
||||
else:
|
||||
reference = value.reference
|
||||
if not isinstance(reference, str) or not reference:
|
||||
return None
|
||||
mapping = AgentStubFileMapping(transfer_method=transfer_method, reference=reference)
|
||||
return mapping.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
mapping = AgentStubFileMapping.model_validate(value)
|
||||
return mapping.model_dump(mode="json", exclude_none=True)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_output_config(declared_outputs: Sequence[DeclaredOutputConfig]) -> AgentBackendOutputConfig | None:
|
||||
"""Build the structured-output layer config sent to Agent backend.
|
||||
@@ -547,42 +648,84 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
|
||||
|
||||
|
||||
def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None:
|
||||
"""Map Agent Soul knowledge config into the fixed Dify knowledge-base layer.
|
||||
"""Map Agent Soul knowledge sets into one Dify knowledge-base layer.
|
||||
|
||||
Normalization intentionally matches the current dify-agent runtime contract:
|
||||
|
||||
- blank or missing dataset ids are ignored;
|
||||
- if no valid dataset ids remain, no knowledge layer is injected;
|
||||
- retrieval mode is always forced to ``multiple`` in this first wiring pass;
|
||||
- ``top_k`` falls back to a stable runtime default when the soul omits it;
|
||||
- ``score_threshold`` is only forwarded when the product config explicitly
|
||||
enables it, otherwise the layer keeps the disabled/default ``0.0`` value;
|
||||
- metadata filtering stays at the layer DTO default (disabled).
|
||||
Agent Soul DTO validation owns malformed set rejection. Runtime mapping is
|
||||
intentionally lossless: every configured set is forwarded with its query
|
||||
policy, dataset refs, retrieval controls, and metadata-filtering controls.
|
||||
``score_threshold=None`` means disabled threshold filtering and maps to the
|
||||
inner retrieval request's ``0.0`` default through the Agent backend DTO.
|
||||
"""
|
||||
dataset_ids = list_configured_knowledge_dataset_ids(agent_soul)
|
||||
if not dataset_ids:
|
||||
if not agent_soul.knowledge.sets:
|
||||
return None
|
||||
|
||||
query_config = agent_soul.knowledge.query_config
|
||||
return DifyKnowledgeBaseLayerConfig(
|
||||
dataset_ids=dataset_ids,
|
||||
retrieval=DifyKnowledgeRetrievalConfig(
|
||||
mode="multiple",
|
||||
top_k=_knowledge_top_k(query_config),
|
||||
score_threshold=_knowledge_score_threshold(query_config),
|
||||
),
|
||||
sets=[
|
||||
DifyKnowledgeSetConfig(
|
||||
id=knowledge_set.id,
|
||||
name=knowledge_set.name,
|
||||
description=knowledge_set.description,
|
||||
datasets=[
|
||||
DifyKnowledgeDatasetConfig(
|
||||
id=dataset.id or "",
|
||||
name=dataset.name,
|
||||
description=dataset.description,
|
||||
)
|
||||
for dataset in knowledge_set.datasets
|
||||
],
|
||||
query=DifyKnowledgeQueryConfig(
|
||||
mode=cast(Literal["user_query", "generated_query"], knowledge_set.query.mode.value),
|
||||
value=knowledge_set.query.value,
|
||||
),
|
||||
retrieval=_knowledge_retrieval_config(knowledge_set.retrieval),
|
||||
metadata_filtering=_knowledge_metadata_filtering_config(knowledge_set.metadata_filtering),
|
||||
)
|
||||
for knowledge_set in agent_soul.knowledge.sets
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _knowledge_top_k(query_config: AgentKnowledgeQueryConfig) -> int:
|
||||
top_k = query_config.top_k
|
||||
return top_k if isinstance(top_k, int) and top_k >= 1 else 4
|
||||
def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> DifyKnowledgeRetrievalConfig:
|
||||
return DifyKnowledgeRetrievalConfig(
|
||||
mode=retrieval.mode,
|
||||
top_k=retrieval.top_k,
|
||||
score_threshold=retrieval.score_threshold or 0.0,
|
||||
reranking_mode=retrieval.reranking_mode,
|
||||
reranking_enable=retrieval.reranking_enable,
|
||||
reranking_model=DifyKnowledgeRerankingModelConfig(
|
||||
provider=retrieval.reranking_model.provider,
|
||||
model=retrieval.reranking_model.model,
|
||||
)
|
||||
if retrieval.reranking_model is not None
|
||||
else None,
|
||||
weights=cast(dict[str, Any], retrieval.weights.model_dump(mode="json", exclude_none=True))
|
||||
if retrieval.weights is not None
|
||||
else None,
|
||||
model=_knowledge_model_config(retrieval.model),
|
||||
)
|
||||
|
||||
|
||||
def _knowledge_score_threshold(query_config: AgentKnowledgeQueryConfig) -> float:
|
||||
if query_config.score_threshold_enabled and query_config.score_threshold is not None:
|
||||
return query_config.score_threshold
|
||||
return 0.0
|
||||
def _knowledge_metadata_filtering_config(
|
||||
metadata_filtering: AgentKnowledgeMetadataFilteringConfig,
|
||||
) -> DifyKnowledgeMetadataFilteringConfig:
|
||||
return DifyKnowledgeMetadataFilteringConfig(
|
||||
mode=metadata_filtering.mode,
|
||||
model_config=_knowledge_model_config(metadata_filtering.metadata_model_config),
|
||||
conditions=cast(Any, metadata_filtering.conditions.model_dump(mode="json"))
|
||||
if metadata_filtering.conditions is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
def _knowledge_model_config(model: AgentKnowledgeModelConfig | None) -> DifyKnowledgeModelConfig | None:
|
||||
if model is None:
|
||||
return None
|
||||
return DifyKnowledgeModelConfig(
|
||||
provider=model.provider,
|
||||
name=model.name,
|
||||
mode=model.mode,
|
||||
completion_params=model.completion_params,
|
||||
)
|
||||
|
||||
|
||||
def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None:
|
||||
|
||||
@@ -18,6 +18,7 @@ from models.agent_config_entities import (
|
||||
)
|
||||
from models.model import UploadFile
|
||||
from models.workflow import Workflow
|
||||
from services.agent.knowledge_datasets import list_missing_tenant_knowledge_dataset_ids
|
||||
|
||||
from .entities import DifyAgentNodeData
|
||||
|
||||
@@ -146,6 +147,7 @@ class WorkflowAgentNodeValidator:
|
||||
)
|
||||
cls._validate_agent_soul_env(binding=binding, agent_soul=agent_soul)
|
||||
cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul)
|
||||
cls._validate_agent_soul_knowledge(binding=binding, agent_soul=agent_soul)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
|
||||
|
||||
@@ -364,6 +366,24 @@ class WorkflowAgentNodeValidator:
|
||||
)
|
||||
cli_tool_names.add(normalized_name)
|
||||
|
||||
@classmethod
|
||||
def _validate_agent_soul_knowledge(
|
||||
cls,
|
||||
*,
|
||||
binding: WorkflowAgentNodeBinding,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> None:
|
||||
"""Validate knowledge set dataset rows against the publishing tenant."""
|
||||
missing_ids = list_missing_tenant_knowledge_dataset_ids(
|
||||
tenant_id=binding.tenant_id,
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
if missing_ids:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} references missing or out-of-scope knowledge datasets: "
|
||||
f"{', '.join(missing_ids)}."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_agent_soul_env(
|
||||
cls,
|
||||
|
||||
@@ -6,6 +6,7 @@ from pydantic import Field, field_validator
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import to_timestamp
|
||||
from models.agent import (
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentIconType,
|
||||
AgentKind,
|
||||
@@ -47,6 +48,18 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel):
|
||||
created_at: int | None = None
|
||||
|
||||
|
||||
class AgentConfigDraftSummaryResponse(ResponseModel):
|
||||
id: str
|
||||
agent_id: str
|
||||
draft_type: AgentConfigDraftType
|
||||
account_id: str | None = None
|
||||
base_snapshot_id: str | None = None
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: int | None = None
|
||||
updated_at: int | None = None
|
||||
|
||||
|
||||
class AgentPublishedReferenceResponse(ResponseModel):
|
||||
app_id: str
|
||||
app_name: str
|
||||
@@ -72,6 +85,8 @@ class AgentRosterResponse(ResponseModel):
|
||||
scope: AgentScope
|
||||
source: AgentSource
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
workflow_id: str | None = None
|
||||
workflow_node_id: str | None = None
|
||||
active_config_snapshot_id: str | None = None
|
||||
@@ -292,14 +307,24 @@ class AgentConfigSnapshotListResponse(ResponseModel):
|
||||
class AgentConfigSnapshotRestoreResponse(ResponseModel):
|
||||
result: Literal["success"]
|
||||
active_config_snapshot_id: str
|
||||
draft_config_id: str | None = None
|
||||
restored_version_id: str | None = None
|
||||
|
||||
|
||||
class AgentComposerAgentResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
role: str | None = None
|
||||
icon_type: str | None = None
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
scope: AgentScope
|
||||
source: AgentSource | None = None
|
||||
status: AgentStatus
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
active_config_snapshot_id: str | None = None
|
||||
|
||||
|
||||
@@ -343,6 +368,9 @@ class WorkflowAgentComposerResponse(ResponseModel):
|
||||
impact_summary: AgentComposerImpactResponse | None = None
|
||||
validation: "ComposerValidationFindingsResponse | None" = None
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
chat_endpoint: str | None = None
|
||||
workflow_id: str | None = None
|
||||
node_id: str | None = None
|
||||
|
||||
@@ -350,10 +378,15 @@ class WorkflowAgentComposerResponse(ResponseModel):
|
||||
class AgentAppComposerResponse(ResponseModel):
|
||||
variant: Literal[ComposerVariant.AGENT_APP]
|
||||
agent: AgentComposerAgentResponse
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
|
||||
draft: AgentConfigDraftSummaryResponse | None = None
|
||||
agent_soul: AgentSoulConfig
|
||||
save_options: list[ComposerSaveStrategy]
|
||||
validation: "ComposerValidationFindingsResponse | None" = None
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
chat_endpoint: str | None = None
|
||||
|
||||
|
||||
class ComposerValidationWarningResponse(ResponseModel):
|
||||
@@ -400,10 +433,22 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
|
||||
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerKnowledgeDatasetCandidateResponse(AgentKnowledgeDatasetConfig):
|
||||
missing: bool = False
|
||||
|
||||
|
||||
class AgentComposerKnowledgeSetCandidateResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
datasets: list[AgentComposerKnowledgeDatasetCandidateResponse] = Field(default_factory=list)
|
||||
missing_dataset_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerSoulCandidatesResponse(ResponseModel):
|
||||
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
|
||||
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
|
||||
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
|
||||
knowledge_sets: list[AgentComposerKnowledgeSetCandidateResponse] = Field(default_factory=list)
|
||||
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
-13
@@ -1,16 +1,5 @@
|
||||
"""add workflow_version to workflow_agent_node_bindings
|
||||
|
||||
Restores the stage 1 §5.3 unique key
|
||||
``(tenant_id, workflow_id, workflow_version, node_id)`` so draft and published
|
||||
workflow bindings can coexist at the same workflow_id once we want to track
|
||||
them per workflow version. ``workflow_version`` mirrors ``workflows.version``
|
||||
("draft" or a published version string).
|
||||
|
||||
Because the New Agent Experience feature is pre-release, this table is empty
|
||||
in every environment that matters; the ``server_default='draft'`` only exists
|
||||
to keep developer-local rows valid during the alter and is dropped immediately
|
||||
afterward so application code must specify ``workflow_version`` explicitly.
|
||||
|
||||
Revision ID: 97e2e1a644e8
|
||||
Revises: f8b6b7e9c421
|
||||
Create Date: 2026-05-25 11:43:37.611300
|
||||
@@ -33,10 +22,8 @@ def upgrade():
|
||||
'workflow_version',
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
server_default='draft',
|
||||
)
|
||||
)
|
||||
batch_op.alter_column('workflow_version', server_default=None)
|
||||
batch_op.drop_constraint(
|
||||
batch_op.f('workflow_agent_node_binding_node_unique'), type_='unique'
|
||||
)
|
||||
|
||||
@@ -18,8 +18,7 @@ depends_on = None
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False, server_default=""))
|
||||
batch_op.alter_column("role", server_default=None)
|
||||
batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False))
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
+1
-44
@@ -6,15 +6,9 @@ Create Date: 2026-06-18 23:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.engine.mock import MockConnection
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b2515f9d4c2a"
|
||||
@@ -37,46 +31,9 @@ def upgrade() -> None:
|
||||
"agent_drive_files",
|
||||
["tenant_id", "agent_id", "is_skill", "key"],
|
||||
)
|
||||
_remove_skills_files_from_snapshots()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files")
|
||||
op.drop_column("agent_drive_files", "skill_metadata")
|
||||
op.drop_column("agent_drive_files", "is_skill")
|
||||
|
||||
|
||||
def _remove_skills_files_from_snapshots() -> None:
|
||||
connection = op.get_bind()
|
||||
if connection is None or isinstance(connection, MockConnection):
|
||||
return
|
||||
snapshots = sa.table(
|
||||
"agent_config_snapshots",
|
||||
sa.column("id", sa.String()),
|
||||
sa.column("config_snapshot", sa.Text()),
|
||||
)
|
||||
rows = connection.execute(sa.select(snapshots.c.id, snapshots.c.config_snapshot)).fetchall()
|
||||
for row in rows:
|
||||
cleaned = _strip_skills_files(row.config_snapshot)
|
||||
if cleaned is None:
|
||||
continue
|
||||
connection.execute(
|
||||
snapshots.update()
|
||||
.where(snapshots.c.id == row.id)
|
||||
.values(config_snapshot=json.dumps(cleaned, separators=(",", ":"), sort_keys=True))
|
||||
)
|
||||
|
||||
|
||||
def _strip_skills_files(raw_snapshot: Any) -> dict[str, Any] | None:
|
||||
if raw_snapshot is None:
|
||||
return None
|
||||
if isinstance(raw_snapshot, str):
|
||||
snapshot = json.loads(raw_snapshot)
|
||||
elif isinstance(raw_snapshot, dict):
|
||||
snapshot = dict(raw_snapshot)
|
||||
else:
|
||||
snapshot = dict(raw_snapshot)
|
||||
if not isinstance(snapshot, dict) or "skills_files" not in snapshot:
|
||||
return None
|
||||
snapshot.pop("skills_files", None)
|
||||
return snapshot
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"""add input placeholder to sites
|
||||
|
||||
Revision ID: a6f1c9d2e8b4
|
||||
Revises: d9e8f7a6b5c4
|
||||
Create Date: 2026-06-24 19:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a6f1c9d2e8b4"
|
||||
down_revision = "d9e8f7a6b5c4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("sites", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("input_placeholder", sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("sites", schema=None) as batch_op:
|
||||
batch_op.drop_column("input_placeholder")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add agent config drafts
|
||||
|
||||
Revision ID: e4f5a6b7c8d9
|
||||
Revises: a6f1c9d2e8b4
|
||||
Create Date: 2026-06-24 20:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e4f5a6b7c8d9"
|
||||
down_revision = "a6f1c9d2e8b4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"agent_config_drafts",
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("draft_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("account_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("draft_owner_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("base_snapshot_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("config_snapshot", models.types.LongText(), nullable=False),
|
||||
sa.Column("created_by", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("updated_by", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("agent_config_draft_pkey")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"agent_id",
|
||||
"draft_type",
|
||||
"draft_owner_key",
|
||||
name=op.f("agent_config_draft_agent_type_account_unique"),
|
||||
),
|
||||
)
|
||||
op.create_index("agent_config_draft_tenant_agent_idx", "agent_config_drafts", ["tenant_id", "agent_id"])
|
||||
op.create_index(
|
||||
"agent_config_draft_base_snapshot_idx",
|
||||
"agent_config_drafts",
|
||||
["tenant_id", "base_snapshot_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("agent_config_draft_base_snapshot_idx", table_name="agent_config_drafts")
|
||||
op.drop_index("agent_config_draft_tenant_agent_idx", table_name="agent_config_drafts")
|
||||
op.drop_table("agent_config_drafts")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add agent backing app id
|
||||
|
||||
Revision ID: a2b3c4d5e6f7
|
||||
Revises: e4f5a6b7c8d9
|
||||
Create Date: 2026-06-25 11:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a2b3c4d5e6f7"
|
||||
down_revision = "e4f5a6b7c8d9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("backing_app_id", models.types.StringUUID(), nullable=True))
|
||||
op.create_index("agent_tenant_backing_app_id_idx", "agents", ["tenant_id", "backing_app_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("agent_tenant_backing_app_id_idx", table_name="agents")
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.drop_column("backing_app_id")
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""add agent active config is published
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: a2b3c4d5e6f7
|
||||
Create Date: 2026-06-26 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c3d4e5f6a7b8"
|
||||
down_revision = "a2b3c4d5e6f7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"active_config_is_published",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("false"),
|
||||
nullable=False,
|
||||
comment=(
|
||||
"Whether the normal shared Agent draft has been published into the active config snapshot. "
|
||||
"User-scoped debug drafts do not affect this flag."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.drop_column("active_config_is_published")
|
||||
@@ -10,6 +10,8 @@ from .account import (
|
||||
)
|
||||
from .agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -154,6 +156,8 @@ __all__ = [
|
||||
"AccountStatus",
|
||||
"AccountTrialAppRecord",
|
||||
"Agent",
|
||||
"AgentConfigDraft",
|
||||
"AgentConfigDraftType",
|
||||
"AgentConfigRevision",
|
||||
"AgentConfigRevisionOperation",
|
||||
"AgentConfigSnapshot",
|
||||
|
||||
+71
-3
@@ -85,6 +85,17 @@ class AgentConfigRevisionOperation(StrEnum):
|
||||
SAVE_TO_ROSTER = "save_to_roster"
|
||||
# Switches the Agent's current published config back to an existing version.
|
||||
RESTORE_VERSION = "restore_version"
|
||||
# Publishes the editable Agent Soul draft as a new immutable version.
|
||||
PUBLISH_DRAFT = "publish_draft"
|
||||
|
||||
|
||||
class AgentConfigDraftType(StrEnum):
|
||||
"""Editable Agent Soul draft workspace type."""
|
||||
|
||||
# Shared Agent Console draft edited by users before publishing.
|
||||
DRAFT = "draft"
|
||||
# Per-editor build draft mutated during debug/build mode.
|
||||
DEBUG_BUILD = "debug_build"
|
||||
|
||||
|
||||
class WorkflowAgentBindingType(StrEnum):
|
||||
@@ -134,6 +145,7 @@ class Agent(DefaultFieldsMixin, Base):
|
||||
Index("agent_tenant_scope_idx", "tenant_id", "scope"),
|
||||
Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"),
|
||||
Index("agent_tenant_app_id_idx", "tenant_id", "app_id"),
|
||||
Index("agent_tenant_backing_app_id_idx", "tenant_id", "backing_app_id"),
|
||||
Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"),
|
||||
Index(
|
||||
"agent_tenant_invitable_idx",
|
||||
@@ -162,12 +174,30 @@ class Agent(DefaultFieldsMixin, Base):
|
||||
scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False)
|
||||
source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False)
|
||||
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
backing_app_id: Mapped[str | None] = mapped_column(
|
||||
StringUUID,
|
||||
nullable=True,
|
||||
comment=(
|
||||
"Runtime Agent App used for chat/log/monitoring. For workflow-only agents, "
|
||||
"app_id remains the parent workflow app id and this points to the hidden backing app."
|
||||
),
|
||||
)
|
||||
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
active_config_has_model: Mapped[bool] = mapped_column(
|
||||
sa.Boolean, nullable=False, default=False, server_default=sa.text("false")
|
||||
)
|
||||
active_config_is_published: Mapped[bool] = mapped_column(
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.text("false"),
|
||||
comment=(
|
||||
"Whether the normal shared Agent draft has been published into the active config snapshot. "
|
||||
"User-scoped debug drafts do not affect this flag."
|
||||
),
|
||||
)
|
||||
status: Mapped[AgentStatus] = mapped_column(
|
||||
EnumText(AgentStatus, length=32), nullable=False, default=AgentStatus.ACTIVE
|
||||
)
|
||||
@@ -210,6 +240,44 @@ class AgentDebugConversation(DefaultFieldsMixin, Base):
|
||||
conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
|
||||
|
||||
class AgentConfigDraft(DefaultFieldsMixin, Base):
|
||||
"""Editable Agent Soul draft separated from immutable published snapshots."""
|
||||
|
||||
__tablename__ = "agent_config_drafts"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="agent_config_draft_pkey"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"agent_id",
|
||||
"draft_type",
|
||||
"draft_owner_key",
|
||||
name="agent_config_draft_agent_type_account_unique",
|
||||
),
|
||||
Index("agent_config_draft_tenant_agent_idx", "tenant_id", "agent_id"),
|
||||
Index("agent_config_draft_base_snapshot_idx", "tenant_id", "base_snapshot_id"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
draft_type: Mapped[AgentConfigDraftType] = mapped_column(EnumText(AgentConfigDraftType, length=32), nullable=False)
|
||||
account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
@property
|
||||
def config_snapshot_dict(self) -> dict[str, Any]:
|
||||
if not self.config_snapshot:
|
||||
return {}
|
||||
if hasattr(self.config_snapshot, "model_dump"):
|
||||
return self.config_snapshot.model_dump(mode="json")
|
||||
if isinstance(self.config_snapshot, str):
|
||||
return json.loads(self.config_snapshot)
|
||||
return dict(self.config_snapshot)
|
||||
|
||||
|
||||
class AgentConfigSnapshot(DefaultFieldsMixin, Base):
|
||||
"""Immutable Agent Soul snapshot.
|
||||
|
||||
@@ -355,9 +423,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base):
|
||||
agent_config_snapshot_id / composition_layer_specs`` columns are set.
|
||||
- Agent App conversations: ``owner_type = conversation``; the
|
||||
``conversation_id`` column is set and the workflow columns stay NULL.
|
||||
Published/web/API runs scope runtime state by ``agent_config_snapshot_id``;
|
||||
console debugger runs may keep it NULL so prompt-only draft saves can reuse
|
||||
the same preview conversation state while executing the latest Agent Soul.
|
||||
Runtime state is scoped by ``agent_config_snapshot_id``. For published
|
||||
web/API runs this points to an immutable AgentConfigSnapshot; for console
|
||||
debugger/build runs it points to the editable AgentConfigDraft row.
|
||||
|
||||
The snapshot is runtime state returned by Agent backend, kept separate from
|
||||
Agent Soul snapshots and workflow node-job config.
|
||||
|
||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Any, Final, Literal
|
||||
from typing import Annotated, Any, Final, Literal, Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator
|
||||
|
||||
from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator
|
||||
from core.workflow.file_reference import is_canonical_file_reference
|
||||
from graphon.file import FileTransferMethod
|
||||
|
||||
@@ -161,6 +162,11 @@ class AgentSkillRefConfig(AgentFlexibleConfig):
|
||||
manifest_files: list[str] | None = None
|
||||
|
||||
|
||||
class AgentSoulFilesConfig(BaseModel):
|
||||
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
|
||||
files: list[AgentFileRefConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentPermissionConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@@ -236,17 +242,161 @@ class AgentCliToolConfig(AgentFlexibleConfig):
|
||||
inferred_from: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):
|
||||
class AgentKnowledgeDatasetConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str | None = Field(default=None, max_length=255)
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AgentKnowledgeQueryConfig(AgentFlexibleConfig):
|
||||
query: str | None = None
|
||||
class AgentKnowledgeQueryConfig(BaseModel):
|
||||
"""Per-set query policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
|
||||
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
|
||||
set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
``value`` while ``generated_query`` leaves that value empty.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: AgentKnowledgeQueryMode
|
||||
value: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query(self) -> Self:
|
||||
if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip():
|
||||
raise ValueError("knowledge query.value is required for user_query mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeModelConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
mode: str = Field(min_length=1, max_length=64)
|
||||
completion_params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentKnowledgeRerankingModelConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: str = Field(min_length=1, max_length=255)
|
||||
model: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class AgentKnowledgeWeightedScoreConfig(AgentFlexibleConfig):
|
||||
weight_type: str | None = Field(default=None, max_length=64)
|
||||
vector_setting: dict[str, Any] | None = None
|
||||
keyword_setting: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AgentKnowledgeRetrievalConfig(BaseModel):
|
||||
"""Per-set retrieval policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Retrieval settings now live on each knowledge set instead of one shared
|
||||
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
|
||||
``single`` retrieval with a required model config.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: Literal["single", "multiple"]
|
||||
top_k: int | None = Field(default=None, ge=1)
|
||||
score_threshold: float | None = Field(default=None, ge=0, le=1)
|
||||
score_threshold_enabled: bool | None = None
|
||||
reranking_mode: str = "reranking_model"
|
||||
reranking_enable: bool = True
|
||||
reranking_model: AgentKnowledgeRerankingModelConfig | None = None
|
||||
weights: AgentKnowledgeWeightedScoreConfig | None = None
|
||||
model: AgentKnowledgeModelConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_fields(self) -> Self:
|
||||
if self.mode == "multiple" and self.top_k is None:
|
||||
raise ValueError("knowledge retrieval.top_k is required for multiple mode")
|
||||
if self.mode == "single" and self.model is None:
|
||||
raise ValueError("knowledge retrieval.model is required for single mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataCondition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
comparison_operator: SupportedComparisonOperator
|
||||
value: ConditionValue = None
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataConditions(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
logical_operator: Literal["and", "or"] = "and"
|
||||
conditions: list[AgentKnowledgeMetadataCondition] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataFilteringConfig(BaseModel):
|
||||
"""Per-set metadata filtering policy.
|
||||
|
||||
The Python attribute uses ``metadata_model_config`` for clarity because the
|
||||
model belongs to metadata filtering specifically, while the external API and
|
||||
generated schema keep the historical ``model_config`` field name via alias.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
mode: Literal["disabled", "automatic", "manual"] = "disabled"
|
||||
# Internal name is explicit; wire format remains ``model_config``.
|
||||
metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config")
|
||||
conditions: AgentKnowledgeMetadataConditions | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_fields(self) -> Self:
|
||||
if self.mode == "automatic" and self.metadata_model_config is None:
|
||||
raise ValueError("metadata_filtering.model_config is required for automatic mode")
|
||||
if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions):
|
||||
raise ValueError("metadata_filtering.conditions is required for manual mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeSetConfig(BaseModel):
|
||||
"""One explicit knowledge set in Agent v2.
|
||||
|
||||
``knowledge.sets`` replaces the old flat knowledge config. Each set owns
|
||||
its datasets plus query, retrieval, and metadata policies. An individual
|
||||
set must contain at least one dataset id even though the overall knowledge
|
||||
section may be empty, which is how callers express "no knowledge layer".
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
datasets: list[AgentKnowledgeDatasetConfig]
|
||||
query: AgentKnowledgeQueryConfig
|
||||
retrieval: AgentKnowledgeRetrievalConfig
|
||||
metadata_filtering: AgentKnowledgeMetadataFilteringConfig = Field(
|
||||
default_factory=AgentKnowledgeMetadataFilteringConfig
|
||||
)
|
||||
|
||||
@field_validator("id", "name")
|
||||
@classmethod
|
||||
def validate_non_blank_identity(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("knowledge set id and name must not be blank")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_datasets(self) -> Self:
|
||||
dataset_ids = [(dataset.id or "").strip() for dataset in self.datasets]
|
||||
if not dataset_ids or any(not dataset_id for dataset_id in dataset_ids):
|
||||
raise ValueError("knowledge set requires at least one dataset id")
|
||||
if len(dataset_ids) != len(set(dataset_ids)):
|
||||
raise ValueError("knowledge set dataset ids must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class AgentHumanContactConfig(AgentFlexibleConfig):
|
||||
@@ -453,9 +603,28 @@ class AgentSoulToolsConfig(BaseModel):
|
||||
|
||||
|
||||
class AgentSoulKnowledgeConfig(BaseModel):
|
||||
datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
|
||||
query_mode: AgentKnowledgeQueryMode | None = None
|
||||
query_config: AgentKnowledgeQueryConfig = Field(default_factory=AgentKnowledgeQueryConfig)
|
||||
"""Top-level Agent v2 knowledge config.
|
||||
|
||||
Agent v2 models knowledge as explicit sets instead of one flat
|
||||
``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
|
||||
list means no knowledge layer should be emitted at runtime, while set-name
|
||||
uniqueness stays case-insensitive because runtime selection addresses sets
|
||||
by name.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sets: list[AgentKnowledgeSetConfig] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_sets(self) -> Self:
|
||||
set_ids = [item.id.strip() for item in self.sets]
|
||||
if len(set_ids) != len(set(set_ids)):
|
||||
raise ValueError("knowledge set ids must be unique")
|
||||
set_names = [item.name.strip().lower() for item in self.sets]
|
||||
if len(set_names) != len(set(set_names)):
|
||||
raise ValueError("knowledge set names must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class AgentSoulHumanConfig(BaseModel):
|
||||
@@ -513,6 +682,7 @@ class AgentSoulConfig(BaseModel):
|
||||
knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig)
|
||||
human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig)
|
||||
env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig)
|
||||
files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
|
||||
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
|
||||
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
|
||||
model: AgentSoulModelConfig | None = None
|
||||
|
||||
+10
-3
@@ -487,9 +487,15 @@ class App(Base):
|
||||
|
||||
agent = db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.app_id == self.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.tenant_id == self.tenant_id,
|
||||
sa.or_(
|
||||
sa.and_(
|
||||
Agent.app_id == self.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
),
|
||||
Agent.backing_app_id == self.id,
|
||||
),
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
@@ -2183,6 +2189,7 @@ class Site(Base):
|
||||
chat_color_theme_inverted: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
|
||||
copyright = mapped_column(String(255))
|
||||
privacy_policy = mapped_column(String(255))
|
||||
input_placeholder = mapped_column(String(255))
|
||||
show_workflow_steps: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"))
|
||||
use_icon_as_answer_icon: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
|
||||
_custom_disclaimer: Mapped[str] = mapped_column("custom_disclaimer", LongText, default="")
|
||||
|
||||
@@ -465,6 +465,83 @@ Check if activation token is valid
|
||||
| ---- | ----------- |
|
||||
| 204 | Agent service API key deleted |
|
||||
|
||||
### [DELETE] /agent/{agent_id}/build-draft
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent build draft discarded | **application/json**: [AgentSimpleResultResponse](#agentsimpleresultresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/build-draft
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent build draft | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
|
||||
|
||||
### [PUT] /agent/{agent_id}/build-draft
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent build draft saved | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
|
||||
|
||||
### [POST] /agent/{agent_id}/build-draft/apply
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent build draft applied | **application/json**: [AgentBuildDraftApplyResponse](#agentbuilddraftapplyresponse)<br> |
|
||||
|
||||
### [POST] /agent/{agent_id}/build-draft/checkout
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [AgentBuildDraftCheckoutPayload](#agentbuilddraftcheckoutpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent build draft checked out | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/chat-messages
|
||||
Get Agent App chat messages for a conversation with pagination
|
||||
|
||||
@@ -856,6 +933,26 @@ Get Agent App message details by ID
|
||||
| 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)<br> |
|
||||
| 404 | Agent or message not found | |
|
||||
|
||||
### [POST] /agent/{agent_id}/publish
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [AgentPublishPayload](#agentpublishpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent draft published | **application/json**: [AgentPublishResponse](#agentpublishresponse)<br> |
|
||||
| 403 | Insufficient permissions | |
|
||||
|
||||
### [GET] /agent/{agent_id}/referencing-workflows
|
||||
List workflow apps that reference this Agent App's bound Agent (read-only)
|
||||
|
||||
@@ -3767,6 +3864,7 @@ Submit human input form preview for workflow
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| snapshot_id | query | | No | string |
|
||||
| app_id | path | | Yes | string (uuid) |
|
||||
| node_id | path | | Yes | string |
|
||||
|
||||
@@ -12173,9 +12271,14 @@ Default namespace
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes |
|
||||
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
|
||||
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
|
||||
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
|
||||
| app_id | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| chat_endpoint | string | | No |
|
||||
| draft | [AgentConfigDraftSummaryResponse](#agentconfigdraftsummaryresponse) | | No |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
|
||||
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
|
||||
| variant | string | | Yes |
|
||||
@@ -12210,6 +12313,7 @@ Default namespace
|
||||
| active_config_is_published | boolean | | No |
|
||||
| api_base_url | string | | No |
|
||||
| app_id | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| bound_agent_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
@@ -12218,6 +12322,7 @@ Default namespace
|
||||
| description | string | | No |
|
||||
| enable_api | boolean | | Yes |
|
||||
| enable_site | boolean | | Yes |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
@@ -12230,7 +12335,7 @@ Default namespace
|
||||
| name | string | | Yes |
|
||||
| permission_keys | [ string ] | | No |
|
||||
| role | string | | No |
|
||||
| site | [Site](#site) | | No |
|
||||
| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No |
|
||||
| tags | [ [Tag](#tag) ] | | No |
|
||||
| tracing | [JSONValue](#jsonvalue) | | No |
|
||||
| updated_at | integer | | No |
|
||||
@@ -12273,6 +12378,7 @@ default (the config form sends the full desired feature state on save).
|
||||
| active_config_is_published | boolean | | No |
|
||||
| app_id | string | | No |
|
||||
| author_name | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| bound_agent_id | string | | No |
|
||||
| create_user_name | string | | No |
|
||||
| created_at | integer | | No |
|
||||
@@ -12280,6 +12386,7 @@ default (the config form sends the full desired feature state on save).
|
||||
| debug_conversation_id | string | | No |
|
||||
| description | string | | No |
|
||||
| has_draft_trigger | boolean | | No |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
@@ -12338,6 +12445,27 @@ default (the config form sends the full desired feature state on save).
|
||||
| date | string | | Yes |
|
||||
| interactions | number | | Yes |
|
||||
|
||||
#### AgentBuildDraftApplyResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| draft | object | | Yes |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentBuildDraftCheckoutPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| force | boolean | Overwrite the existing current-user build draft | No |
|
||||
|
||||
#### AgentBuildDraftResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_soul | object | | Yes |
|
||||
| draft | object | | Yes |
|
||||
| variant | string | | Yes |
|
||||
|
||||
#### AgentCliToolAuthorizationStatus
|
||||
|
||||
Authorization state for Agent-scoped CLI tools.
|
||||
@@ -12400,10 +12528,18 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot_id | string | | No |
|
||||
| app_id | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| description | string | | Yes |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| id | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| role | string | | No |
|
||||
| scope | [AgentScope](#agentscope) | | Yes |
|
||||
| source | [AgentSource](#agentsource) | | No |
|
||||
| status | [AgentStatus](#agentstatus) | | Yes |
|
||||
|
||||
#### AgentComposerBindingResponse
|
||||
@@ -12456,6 +12592,25 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| current_snapshot_id | string | | No |
|
||||
| workflow_node_count | integer | | Yes |
|
||||
|
||||
#### AgentComposerKnowledgeDatasetCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| id | string | | No |
|
||||
| missing | boolean | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### AgentComposerKnowledgeSetCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentComposerKnowledgeDatasetCandidateResponse](#agentcomposerknowledgedatasetcandidateresponse) ] | | No |
|
||||
| description | string | | No |
|
||||
| id | string | | Yes |
|
||||
| missing_dataset_ids | [ string ] | | No |
|
||||
| name | string | | Yes |
|
||||
|
||||
#### AgentComposerNodeJobCandidatesResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -12471,7 +12626,7 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| cli_tools | [ [AgentCliToolConfig](#agentclitoolconfig) ] | | No |
|
||||
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
| knowledge_sets | [ [AgentComposerKnowledgeSetCandidateResponse](#agentcomposerknowledgesetcandidateresponse) ] | | No |
|
||||
|
||||
#### AgentComposerSoulLockResponse
|
||||
|
||||
@@ -12490,6 +12645,28 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| result | string | | Yes |
|
||||
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
|
||||
|
||||
#### AgentConfigDraftSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| account_id | string | | No |
|
||||
| agent_id | string | | Yes |
|
||||
| base_snapshot_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | | Yes |
|
||||
| id | string | | Yes |
|
||||
| updated_at | integer | | No |
|
||||
| updated_by | string | | No |
|
||||
|
||||
#### AgentConfigDraftType
|
||||
|
||||
Editable Agent Soul draft workspace type.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentConfigDraftType | string | Editable Agent Soul draft workspace type. | |
|
||||
|
||||
#### AgentConfigRevisionOperation
|
||||
|
||||
Audit operation recorded for Agent Soul version/revision changes.
|
||||
@@ -12539,6 +12716,8 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot_id | string | | Yes |
|
||||
| draft_config_id | string | | No |
|
||||
| restored_version_id | string | | No |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentConfigSnapshotSummaryResponse
|
||||
@@ -12793,10 +12972,12 @@ Supported icon storage formats for Agent roster entries.
|
||||
| app_id | string | | No |
|
||||
| archived_at | integer | | No |
|
||||
| archived_by | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| description | string | | Yes |
|
||||
| existing_node_ids | [ string ] | | No |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | [AgentIconType](#agenticontype) | | No |
|
||||
@@ -12865,14 +13046,57 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| id | string | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### AgentKnowledgeQueryConfig
|
||||
#### AgentKnowledgeMetadataCondition
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| query | string | | No |
|
||||
| score_threshold | number | | No |
|
||||
| score_threshold_enabled | boolean | | No |
|
||||
| top_k | integer | | No |
|
||||
| comparison_operator | string, <br>**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes |
|
||||
| name | string | | Yes |
|
||||
| value | string<br>[ string ]<br>number | | No |
|
||||
|
||||
#### AgentKnowledgeMetadataConditions
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [ [AgentKnowledgeMetadataCondition](#agentknowledgemetadatacondition) ] | | No |
|
||||
| logical_operator | string, <br>**Available values:** "and", "or", <br>**Default:** and | *Enum:* `"and"`, `"or"` | No |
|
||||
|
||||
#### AgentKnowledgeMetadataFilteringConfig
|
||||
|
||||
Per-set metadata filtering policy.
|
||||
|
||||
The Python attribute uses ``metadata_model_config`` for clarity because the
|
||||
model belongs to metadata filtering specifically, while the external API and
|
||||
generated schema keep the historical ``model_config`` field name via alias.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [AgentKnowledgeMetadataConditions](#agentknowledgemetadataconditions) | | No |
|
||||
| mode | string, <br>**Available values:** "automatic", "disabled", "manual", <br>**Default:** disabled | *Enum:* `"automatic"`, `"disabled"`, `"manual"` | No |
|
||||
| model_config | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
|
||||
|
||||
#### AgentKnowledgeModelConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| completion_params | object | | No |
|
||||
| mode | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### AgentKnowledgeQueryConfig
|
||||
|
||||
Per-set query policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
|
||||
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
|
||||
set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
``value`` while ``generated_query`` leaves that value empty.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | Yes |
|
||||
| value | string | | No |
|
||||
|
||||
#### AgentKnowledgeQueryMode
|
||||
|
||||
@@ -12880,6 +13104,59 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentKnowledgeQueryMode | string | | |
|
||||
|
||||
#### AgentKnowledgeRerankingModelConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| model | string | | Yes |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### AgentKnowledgeRetrievalConfig
|
||||
|
||||
Per-set retrieval policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Retrieval settings now live on each knowledge set instead of one shared
|
||||
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
|
||||
``single`` retrieval with a required model config.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| mode | string, <br>**Available values:** "multiple", "single" | *Enum:* `"multiple"`, `"single"` | Yes |
|
||||
| model | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
|
||||
| reranking_enable | boolean, <br>**Default:** true | | No |
|
||||
| reranking_mode | string, <br>**Default:** reranking_model | | No |
|
||||
| reranking_model | [AgentKnowledgeRerankingModelConfig](#agentknowledgererankingmodelconfig) | | No |
|
||||
| score_threshold | number | | No |
|
||||
| top_k | integer | | No |
|
||||
| weights | [AgentKnowledgeWeightedScoreConfig](#agentknowledgeweightedscoreconfig) | | No |
|
||||
|
||||
#### AgentKnowledgeSetConfig
|
||||
|
||||
One explicit knowledge set in Agent v2.
|
||||
|
||||
``knowledge.sets`` replaces the old flat knowledge config. Each set owns
|
||||
its datasets plus query, retrieval, and metadata policies. An individual
|
||||
set must contain at least one dataset id even though the overall knowledge
|
||||
section may be empty, which is how callers express "no knowledge layer".
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | Yes |
|
||||
| description | string | | No |
|
||||
| id | string | | Yes |
|
||||
| metadata_filtering | [AgentKnowledgeMetadataFilteringConfig](#agentknowledgemetadatafilteringconfig) | | No |
|
||||
| name | string | | Yes |
|
||||
| query | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | Yes |
|
||||
| retrieval | [AgentKnowledgeRetrievalConfig](#agentknowledgeretrievalconfig) | | Yes |
|
||||
|
||||
#### AgentKnowledgeWeightedScoreConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword_setting | object | | No |
|
||||
| vector_setting | object | | No |
|
||||
| weight_type | string | | No |
|
||||
|
||||
#### AgentLogConversationItemResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13063,6 +13340,21 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentProviderResponse | object | | |
|
||||
|
||||
#### AgentPublishPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| version_note | string | Optional note for this published Agent version | No |
|
||||
|
||||
#### AgentPublishResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot | object | | No |
|
||||
| active_config_snapshot_id | string | | Yes |
|
||||
| draft | object | | No |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentPublishedReferenceResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13120,9 +13412,11 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| app_id | string | | No |
|
||||
| archived_at | integer | | No |
|
||||
| archived_by | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| description | string | | Yes |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | [AgentIconType](#agenticontype) | | No |
|
||||
@@ -13190,6 +13484,27 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| enabled | boolean | | No |
|
||||
| type | string | | No |
|
||||
|
||||
#### AgentSimpleResultResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentSkillRefConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| file_id | string | | No |
|
||||
| full_archive_file_id | string | | No |
|
||||
| full_archive_key | string | | No |
|
||||
| id | string | | No |
|
||||
| manifest_files | [ string ] | | No |
|
||||
| name | string | | No |
|
||||
| path | string | | No |
|
||||
| skill_md_file_id | string | | No |
|
||||
| skill_md_key | string | | No |
|
||||
|
||||
#### AgentSkillUploadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13216,6 +13531,7 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| app_features | [AgentSoulAppFeaturesConfig](#agentsoulappfeaturesconfig) | | No |
|
||||
| app_variables | [ [AppVariableConfig](#appvariableconfig) ] | | No |
|
||||
| env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No |
|
||||
| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No |
|
||||
| human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No |
|
||||
| knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No |
|
||||
| memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No |
|
||||
@@ -13270,6 +13586,13 @@ old Agent tool payloads can be read while new payloads stay explicit.
|
||||
| secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No |
|
||||
| variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No |
|
||||
|
||||
#### AgentSoulFilesConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No |
|
||||
| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
|
||||
|
||||
#### AgentSoulHumanConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13279,11 +13602,17 @@ old Agent tool payloads can be read while new payloads stay explicit.
|
||||
|
||||
#### AgentSoulKnowledgeConfig
|
||||
|
||||
Top-level Agent v2 knowledge config.
|
||||
|
||||
Agent v2 models knowledge as explicit sets instead of one flat
|
||||
``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
|
||||
list means no knowledge layer should be emitted at runtime, while set-name
|
||||
uniqueness stays case-insensitive because runtime selection addresses sets
|
||||
by name.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
| query_config | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | No |
|
||||
| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No |
|
||||
| sets | [ [AgentKnowledgeSetConfig](#agentknowledgesetconfig) ] | | No |
|
||||
|
||||
#### AgentSoulMemoryConfig
|
||||
|
||||
@@ -13756,6 +14085,36 @@ Enum class for api provider schema type.
|
||||
| use_icon_as_answer_icon | boolean | | No |
|
||||
| workflow | [WorkflowPartial](#workflowpartial) | | No |
|
||||
|
||||
#### AppDetailSiteResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| access_token | string | | No |
|
||||
| app_base_url | string | | No |
|
||||
| chat_color_theme | string | | No |
|
||||
| chat_color_theme_inverted | boolean | | No |
|
||||
| code | string | | No |
|
||||
| copyright | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| custom_disclaimer | string | | No |
|
||||
| customize_domain | string | | No |
|
||||
| customize_token_strategy | string | | No |
|
||||
| default_language | string | | No |
|
||||
| description | string | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string<br>[IconType](#icontype) | | No |
|
||||
| icon_url | string | | Yes |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| prompt_public | boolean | | No |
|
||||
| show_workflow_steps | boolean | | No |
|
||||
| title | string | | No |
|
||||
| updated_at | integer | | No |
|
||||
| updated_by | string | | No |
|
||||
| use_icon_as_answer_icon | boolean | | No |
|
||||
|
||||
#### AppDetailWithSite
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13781,7 +14140,7 @@ Enum class for api provider schema type.
|
||||
| model_config | [ModelConfig](#modelconfig) | | No |
|
||||
| name | string | | Yes |
|
||||
| permission_keys | [ string ] | | No |
|
||||
| site | [Site](#site) | | No |
|
||||
| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No |
|
||||
| tags | [ [Tag](#tag) ] | | No |
|
||||
| tracing | [JSONValue](#jsonvalue) | | No |
|
||||
| updated_at | integer | | No |
|
||||
@@ -13925,6 +14284,7 @@ AppMCPServer Status Enum
|
||||
| description | string | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| prompt_public | boolean | | Yes |
|
||||
| show_workflow_steps | boolean | | Yes |
|
||||
@@ -13952,6 +14312,7 @@ AppMCPServer Status Enum
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| prompt_public | boolean | | No |
|
||||
| show_workflow_steps | boolean | | No |
|
||||
@@ -14185,6 +14546,7 @@ Button styles for user actions.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conversation_id | string | Conversation ID | No |
|
||||
| draft_type | string, <br>**Available values:** "debug_build", "draft", <br>**Default:** draft | Agent App debug config source. Use debug_build while the Agent is in build mode.<br>*Enum:* `"debug_build"`, `"draft"` | No |
|
||||
| files | [ object ] | Uploaded files | No |
|
||||
| inputs | object | | Yes |
|
||||
| model_config | object | | No |
|
||||
@@ -19265,6 +19627,7 @@ Simple provider entity response.
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| icon_url | string | | Yes |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| show_workflow_steps | boolean | | Yes |
|
||||
| title | string | | Yes |
|
||||
@@ -20352,6 +20715,12 @@ How a workflow node is bound to an Agent.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| WorkflowAgentBindingType | string | How a workflow node is bound to an Agent. | |
|
||||
|
||||
#### WorkflowAgentComposerQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| snapshot_id | string | | No |
|
||||
|
||||
#### WorkflowAgentComposerResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -20360,8 +20729,11 @@ How a workflow node is bound to an Agent.
|
||||
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | No |
|
||||
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
|
||||
| app_id | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No |
|
||||
| chat_endpoint | string | | No |
|
||||
| effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No |
|
||||
| node_id | string | | No |
|
||||
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
|
||||
|
||||
@@ -3999,6 +3999,7 @@ Model class for provider with models response.
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| icon_url | string | | Yes |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| show_workflow_steps | boolean | | Yes |
|
||||
| title | string | | Yes |
|
||||
|
||||
@@ -1006,6 +1006,7 @@ Returns Server-Sent Events stream.
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| icon_url | string | | No |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| prompt_public | boolean | | No |
|
||||
| show_workflow_steps | boolean | | No |
|
||||
|
||||
@@ -25,6 +25,7 @@ from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
)
|
||||
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
|
||||
|
||||
MAX_CANDIDATES_PER_LIST = 200
|
||||
|
||||
@@ -139,19 +140,34 @@ def soul_candidates(
|
||||
|
||||
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
|
||||
|
||||
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
|
||||
dataset_ids = list_agent_soul_knowledge_dataset_ids(soul)
|
||||
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
|
||||
knowledge_datasets: list[dict[str, Any]] = []
|
||||
for dataset in soul.knowledge.datasets:
|
||||
if not dataset.id:
|
||||
continue
|
||||
row = dataset_rows.get(dataset.id)
|
||||
knowledge_datasets.append(
|
||||
knowledge_sets: list[dict[str, Any]] = []
|
||||
for knowledge_set in soul.knowledge.sets:
|
||||
missing_dataset_ids: list[str] = []
|
||||
datasets: list[dict[str, Any]] = []
|
||||
for dataset in knowledge_set.datasets:
|
||||
dataset_id = (dataset.id or "").strip()
|
||||
if not dataset_id:
|
||||
continue
|
||||
row = dataset_rows.get(dataset_id)
|
||||
if row is None:
|
||||
missing_dataset_ids.append(dataset_id)
|
||||
datasets.append(
|
||||
{
|
||||
"id": dataset_id,
|
||||
"name": (getattr(row, "name", None) or dataset.name or dataset_id),
|
||||
"description": getattr(row, "description", None) or dataset.description,
|
||||
"missing": row is None,
|
||||
}
|
||||
)
|
||||
knowledge_sets.append(
|
||||
{
|
||||
"id": dataset.id,
|
||||
"name": (getattr(row, "name", None) or dataset.name or dataset.id),
|
||||
"description": getattr(row, "description", None) or dataset.description,
|
||||
"missing": row is None,
|
||||
"id": knowledge_set.id,
|
||||
"name": knowledge_set.name,
|
||||
"description": knowledge_set.description,
|
||||
"datasets": datasets,
|
||||
"missing_dataset_ids": missing_dataset_ids,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -161,7 +177,7 @@ def soul_candidates(
|
||||
lists = {
|
||||
"dify_tools": dify_tools,
|
||||
"cli_tools": cli_tools,
|
||||
"knowledge_datasets": knowledge_datasets,
|
||||
"knowledge_sets": knowledge_sets,
|
||||
"human_contacts": human_contacts,
|
||||
}
|
||||
capped: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
@@ -11,6 +11,8 @@ from libs.helper import to_timestamp
|
||||
from models import Account
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -37,6 +39,10 @@ from services.agent.errors import (
|
||||
AgentVersionNotFoundError,
|
||||
InvalidComposerConfigError,
|
||||
)
|
||||
from services.agent.knowledge_datasets import (
|
||||
get_tenant_knowledge_dataset_rows,
|
||||
list_missing_tenant_knowledge_dataset_ids,
|
||||
)
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AppService, CreateAppParams
|
||||
from services.entities.agent_entities import (
|
||||
@@ -90,26 +96,68 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non
|
||||
ComposerConfigValidator.validate_draft_save_payload(payload)
|
||||
|
||||
|
||||
def _agent_soul_config_json(agent_soul: AgentSoulConfig | dict[str, Any]) -> dict[str, Any]:
|
||||
return AgentSoulConfig.model_validate(agent_soul).model_dump(mode="json")
|
||||
|
||||
|
||||
class AgentComposerService:
|
||||
@classmethod
|
||||
def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]:
|
||||
def load_workflow_composer(
|
||||
cls, *, tenant_id: str, app_id: str, node_id: str, snapshot_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
if not binding:
|
||||
if snapshot_id:
|
||||
raise AgentVersionNotFoundError()
|
||||
return cls._empty_workflow_state(app_id=app_id, workflow_id=workflow.id, node_id=node_id)
|
||||
|
||||
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
|
||||
version = cls._workflow_composer_version(
|
||||
tenant_id=tenant_id,
|
||||
binding=binding,
|
||||
agent=agent,
|
||||
snapshot_id=snapshot_id,
|
||||
)
|
||||
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
|
||||
|
||||
@classmethod
|
||||
def _workflow_composer_version(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
binding: WorkflowAgentNodeBinding,
|
||||
agent: Agent | None,
|
||||
snapshot_id: str | None,
|
||||
) -> AgentConfigSnapshot | None:
|
||||
if snapshot_id:
|
||||
if agent is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT:
|
||||
if agent.scope != AgentScope.ROSTER:
|
||||
raise AgentVersionNotFoundError()
|
||||
elif binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
|
||||
if (
|
||||
agent.scope != AgentScope.WORKFLOW_ONLY
|
||||
or agent.app_id != binding.app_id
|
||||
or agent.workflow_id != binding.workflow_id
|
||||
or agent.workflow_node_id != binding.node_id
|
||||
):
|
||||
raise AgentVersionNotFoundError()
|
||||
else:
|
||||
raise AgentVersionNotFoundError()
|
||||
return cls._require_version(tenant_id=tenant_id, agent_id=agent.id, version_id=snapshot_id)
|
||||
|
||||
version_id = (
|
||||
agent.active_config_snapshot_id
|
||||
if agent and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
|
||||
else binding.current_snapshot_id
|
||||
)
|
||||
version = cls._get_version_if_present(
|
||||
return cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id if agent else None,
|
||||
version_id=version_id,
|
||||
)
|
||||
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
|
||||
|
||||
@classmethod
|
||||
def save_workflow_composer(
|
||||
@@ -120,6 +168,7 @@ class AgentComposerService:
|
||||
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
|
||||
@@ -259,31 +308,37 @@ class AgentComposerService:
|
||||
|
||||
@classmethod
|
||||
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
agent = cls._require_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent)
|
||||
|
||||
@classmethod
|
||||
def load_agent_composer(cls, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent)
|
||||
|
||||
@classmethod
|
||||
def _load_agent_composer_for_agent(cls, *, tenant_id: str, agent: Agent) -> dict[str, Any]:
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=agent.updated_by or agent.created_by,
|
||||
)
|
||||
if not agent:
|
||||
raise AgentNotFoundError()
|
||||
version = cls._require_version(
|
||||
version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
return {
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"agent": cls._serialize_agent(agent),
|
||||
"active_config_snapshot": cls._serialize_version(version),
|
||||
"agent_soul": version.config_snapshot_dict,
|
||||
"save_options": [
|
||||
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value,
|
||||
],
|
||||
"draft": cls._serialize_draft(draft),
|
||||
"agent_soul": draft.config_snapshot_dict,
|
||||
"save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
|
||||
"app_id": agent.app_id,
|
||||
"backing_app_id": agent.backing_app_id or agent.app_id,
|
||||
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
|
||||
"chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -292,22 +347,17 @@ class AgentComposerService:
|
||||
) -> dict[str, Any]:
|
||||
if payload.variant != ComposerVariant.AGENT_APP:
|
||||
raise ValueError("Agent App composer endpoint only accepts agent_app variant")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION:
|
||||
raise InvalidComposerConfigError(
|
||||
"Agent App composer only saves the normal draft. Use the publish endpoint to create a version."
|
||||
)
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
if not agent:
|
||||
agent = Agent(
|
||||
tenant_id=tenant_id,
|
||||
@@ -317,6 +367,7 @@ class AgentComposerService:
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
app_id=app_id,
|
||||
backing_app_id=app_id,
|
||||
status=AgentStatus.ACTIVE,
|
||||
created_by=account_id,
|
||||
updated_by=account_id,
|
||||
@@ -327,35 +378,59 @@ class AgentComposerService:
|
||||
except IntegrityError as exc:
|
||||
db.session.rollback()
|
||||
raise AgentNameConflictError() from exc
|
||||
return cls._save_agent_composer_for_agent(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
account_id=account_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id:
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
version_note=payload.version_note,
|
||||
@classmethod
|
||||
def save_agent_composer(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload
|
||||
) -> dict[str, Any]:
|
||||
if payload.variant != ComposerVariant.AGENT_APP:
|
||||
raise ValueError("Agent composer endpoint only accepts agent_app variant")
|
||||
if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION:
|
||||
raise InvalidComposerConfigError(
|
||||
"Agent composer only saves the normal draft. Use the publish endpoint to create a version."
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
else:
|
||||
current_snapshot = cls._require_version(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
version = cls._update_current_version(
|
||||
current_snapshot=current_snapshot,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
version_note=payload.version_note,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.updated_by = account_id
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return cls._save_agent_composer_for_agent(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
account_id=account_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _save_agent_composer_for_agent(
|
||||
cls, *, tenant_id: str, agent: Agent, account_id: str, payload: ComposerSavePayload
|
||||
) -> dict[str, Any]:
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
agent_soul=payload.agent_soul,
|
||||
account_id_for_audit=account_id,
|
||||
)
|
||||
agent.updated_by = account_id
|
||||
agent.active_config_is_published = cls._agent_soul_matches_active_config(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
|
||||
state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id)
|
||||
state["validation"] = cls.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
@@ -363,6 +438,214 @@ class AgentComposerService:
|
||||
)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def _agent_soul_matches_active_config(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> bool:
|
||||
if not agent.active_config_snapshot_id:
|
||||
return False
|
||||
|
||||
active_version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
if not active_version:
|
||||
return False
|
||||
if agent.source == AgentSource.AGENT_APP and not cls._has_publish_visible_revision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
):
|
||||
return False
|
||||
|
||||
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict)
|
||||
|
||||
@classmethod
|
||||
def _has_publish_visible_revision(cls, *, tenant_id: str, agent_id: str, snapshot_id: str) -> bool:
|
||||
revisions = db.session.scalars(
|
||||
select(AgentConfigRevision.operation).where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
AgentConfigRevision.current_snapshot_id == snapshot_id,
|
||||
)
|
||||
).all()
|
||||
|
||||
return any(
|
||||
operation
|
||||
in {
|
||||
AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
}
|
||||
for operation in revisions
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def publish_agent_app_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP:
|
||||
raise AgentNotFoundError()
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=account_id,
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
ComposerConfigValidator.validate_publish_payload(
|
||||
ComposerSavePayload(
|
||||
variant=ComposerVariant.AGENT_APP,
|
||||
agent_soul=agent_soul,
|
||||
save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
|
||||
version_note=version_note,
|
||||
)
|
||||
)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul)
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
version_note=version_note,
|
||||
previous_snapshot_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
agent.updated_by = account_id
|
||||
draft.base_snapshot_id = version.id
|
||||
draft.updated_by = account_id
|
||||
db.session.commit()
|
||||
return {
|
||||
"result": "success",
|
||||
"active_config_snapshot_id": version.id,
|
||||
"active_config_snapshot": cls._serialize_version(version),
|
||||
"draft": cls._serialize_draft(draft),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def checkout_agent_app_build_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, force: bool = False
|
||||
) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
normal_draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=account_id,
|
||||
)
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is not None and not force:
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
if build_draft is None:
|
||||
build_draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
draft_owner_key=account_id,
|
||||
created_by=account_id,
|
||||
)
|
||||
db.session.add(build_draft)
|
||||
build_draft.base_snapshot_id = normal_draft.base_snapshot_id
|
||||
build_draft.config_snapshot = AgentSoulConfig.model_validate(normal_draft.config_snapshot_dict)
|
||||
build_draft.updated_by = account_id
|
||||
db.session.commit()
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
|
||||
@classmethod
|
||||
def load_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
|
||||
@classmethod
|
||||
def save_agent_app_build_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload
|
||||
) -> dict[str, Any]:
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
ComposerConfigValidator.validate_draft_save_payload(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
build_draft = cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
account_id_for_audit=account_id,
|
||||
)
|
||||
db.session.commit()
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
|
||||
@classmethod
|
||||
def apply_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict)
|
||||
normal_draft = cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
agent_soul=applied_agent_soul,
|
||||
account_id_for_audit=account_id,
|
||||
base_snapshot_id=build_draft.base_snapshot_id,
|
||||
)
|
||||
agent.active_config_is_published = cls._agent_soul_matches_active_config(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
agent_soul=applied_agent_soul,
|
||||
)
|
||||
agent.updated_by = account_id
|
||||
db.session.delete(build_draft)
|
||||
db.session.commit()
|
||||
return {"result": "success", "draft": cls._serialize_draft(normal_draft)}
|
||||
|
||||
@classmethod
|
||||
def discard_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is not None:
|
||||
db.session.delete(build_draft)
|
||||
db.session.commit()
|
||||
return {"result": "success"}
|
||||
|
||||
@classmethod
|
||||
def collect_validation_findings(
|
||||
cls,
|
||||
@@ -372,19 +655,15 @@ class AgentComposerService:
|
||||
agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 soft findings, with DB-backed dataset and drive mention checks."""
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
|
||||
mentioned_ids: set[str] = set()
|
||||
if payload.agent_soul is not None:
|
||||
mentioned_ids |= {
|
||||
mention.ref_id
|
||||
for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt)
|
||||
if mention.kind == MentionKind.KNOWLEDGE
|
||||
}
|
||||
existing_dataset_ids: set[str] | None = None
|
||||
if mentioned_ids:
|
||||
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
|
||||
findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
|
||||
existing_knowledge_set_ids = (
|
||||
{knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets}
|
||||
if payload.agent_soul is not None
|
||||
else None
|
||||
)
|
||||
findings = ComposerConfigValidator.collect_soft_findings(
|
||||
payload,
|
||||
existing_knowledge_set_ids=existing_knowledge_set_ids,
|
||||
)
|
||||
if agent_id and payload.agent_soul is not None:
|
||||
findings["warnings"].extend(
|
||||
cls._drive_mention_findings(
|
||||
@@ -395,6 +674,24 @@ class AgentComposerService:
|
||||
)
|
||||
return findings
|
||||
|
||||
@classmethod
|
||||
def validate_knowledge_datasets(cls, *, tenant_id: str, agent_soul: AgentSoulConfig | None) -> None:
|
||||
"""Hard-validate tenant-scoped knowledge set datasets before saving.
|
||||
|
||||
DTO validators own set shape, duplicate set ids/names, and duplicate
|
||||
dataset ids within one set. This service-level check owns database
|
||||
existence and tenant ownership so invalid or cross-tenant datasets fail
|
||||
before Agent Soul snapshots are persisted.
|
||||
"""
|
||||
if agent_soul is None:
|
||||
return
|
||||
missing_ids = list_missing_tenant_knowledge_dataset_ids(tenant_id=tenant_id, agent_soul=agent_soul)
|
||||
if missing_ids:
|
||||
raise InvalidComposerConfigError(
|
||||
"knowledge_dataset_not_found: knowledge sets reference missing or out-of-scope datasets: "
|
||||
+ ", ".join(missing_ids)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None:
|
||||
"""The Agent App's bound roster agent id, if any (validate-endpoint context)."""
|
||||
@@ -509,7 +806,7 @@ class AgentComposerService:
|
||||
|
||||
soul_lists, soul_truncated = soul_candidates(
|
||||
agent_soul=agent_soul,
|
||||
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
|
||||
)
|
||||
truncated = truncated or soul_truncated
|
||||
@@ -529,14 +826,14 @@ class AgentComposerService:
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
@classmethod
|
||||
def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]:
|
||||
def get_agent_app_candidates(cls, *, tenant_id: str, agent_id: str, user_id: str) -> dict[str, Any]:
|
||||
"""Slash-menu data source for the Agent App (Console) composer (ENG-615)."""
|
||||
from services.agent.composer_candidates import soul_candidates
|
||||
|
||||
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
|
||||
agent_soul = cls._load_agent_soul(tenant_id=tenant_id, agent_id=agent_id)
|
||||
soul_lists, truncated = soul_candidates(
|
||||
agent_soul=agent_soul,
|
||||
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
|
||||
)
|
||||
response = ComposerCandidatesResponse(
|
||||
@@ -568,24 +865,18 @@ class AgentComposerService:
|
||||
return cls._parse_soul_snapshot(version)
|
||||
|
||||
@classmethod
|
||||
def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
def _load_agent_soul(cls, *, tenant_id: str, agent_id: str) -> AgentSoulConfig | None:
|
||||
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent is None:
|
||||
return None
|
||||
version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=agent.updated_by or agent.created_by,
|
||||
)
|
||||
return cls._parse_soul_snapshot(version)
|
||||
return AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
|
||||
@staticmethod
|
||||
def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
|
||||
@@ -629,30 +920,6 @@ class AgentComposerService:
|
||||
variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id)
|
||||
return [(variable.name, variable.value_type.value) for variable in variables.variables]
|
||||
|
||||
@staticmethod
|
||||
def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
|
||||
"""Tenant-scoped dataset lookup tolerating malformed ids.
|
||||
|
||||
Mention ids come from user-editable prompt text; a non-UUID id can never
|
||||
match a dataset row, so it is simply absent from the result (-> missing/
|
||||
placeholder semantics) instead of breaking the UUID-typed query.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from services.dataset_service import DatasetService
|
||||
|
||||
valid_ids: list[str] = []
|
||||
for dataset_id in dataset_ids:
|
||||
try:
|
||||
UUID(dataset_id)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
valid_ids.append(dataset_id)
|
||||
if not valid_ids:
|
||||
return {}
|
||||
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
|
||||
return {str(row.id): row for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Workspace Dify Plugin tools, same source as the tool selector.
|
||||
@@ -780,6 +1047,7 @@ class AgentComposerService:
|
||||
raise ValueError("Inline workflow agent binding must point to a workflow-only agent")
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
agent.updated_by = account_id
|
||||
binding.current_snapshot_id = version.id
|
||||
binding.updated_by = account_id
|
||||
@@ -878,6 +1146,7 @@ class AgentComposerService:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
agent.updated_by = account_id
|
||||
binding.current_snapshot_id = version.id
|
||||
if payload.node_job is not None:
|
||||
@@ -908,6 +1177,7 @@ class AgentComposerService:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
agent.updated_by = account_id
|
||||
binding.current_snapshot_id = version.id
|
||||
binding.updated_by = account_id
|
||||
@@ -1028,6 +1298,15 @@ class AgentComposerService:
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
) -> Agent:
|
||||
backing_app = AgentRosterService(db.session).create_hidden_backing_app_for_workflow_agent(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
name=name or f"Workflow Agent {node_id}",
|
||||
description=description,
|
||||
icon_type=icon_type,
|
||||
icon=icon,
|
||||
icon_background=icon_background,
|
||||
)
|
||||
agent = Agent(
|
||||
tenant_id=tenant_id,
|
||||
name=name or f"Workflow Agent {node_id}",
|
||||
@@ -1040,6 +1319,7 @@ class AgentComposerService:
|
||||
scope=AgentScope.WORKFLOW_ONLY,
|
||||
source=AgentSource.WORKFLOW,
|
||||
app_id=app_id,
|
||||
backing_app_id=backing_app.id,
|
||||
workflow_id=workflow_id,
|
||||
workflow_node_id=node_id,
|
||||
status=AgentStatus.ACTIVE,
|
||||
@@ -1058,6 +1338,7 @@ class AgentComposerService:
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
return agent
|
||||
|
||||
@classmethod
|
||||
@@ -1205,6 +1486,7 @@ class AgentComposerService:
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
agent.updated_by = account_id
|
||||
return agent
|
||||
|
||||
@@ -1285,6 +1567,145 @@ class AgentComposerService:
|
||||
or 0
|
||||
) + 1
|
||||
|
||||
@classmethod
|
||||
def _get_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent | None:
|
||||
return db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _require_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent:
|
||||
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
return agent
|
||||
|
||||
@classmethod
|
||||
def _get_agent_draft(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
draft_type: AgentConfigDraftType,
|
||||
account_id: str | None,
|
||||
) -> AgentConfigDraft | None:
|
||||
stmt = select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent_id,
|
||||
AgentConfigDraft.draft_type == draft_type,
|
||||
)
|
||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
|
||||
else:
|
||||
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
|
||||
return db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
|
||||
|
||||
@classmethod
|
||||
def _get_or_create_agent_draft(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
draft_type: AgentConfigDraftType,
|
||||
account_id: str | None,
|
||||
created_by: str | None,
|
||||
) -> AgentConfigDraft:
|
||||
draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=draft_type,
|
||||
account_id=account_id,
|
||||
)
|
||||
if draft is not None:
|
||||
return draft
|
||||
base_snapshot = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
agent_soul = (
|
||||
AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict)
|
||||
if base_snapshot is not None
|
||||
else AgentSoulConfig()
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=draft_type,
|
||||
account_id=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
|
||||
draft_owner_key=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD and account_id else "",
|
||||
base_snapshot_id=base_snapshot.id if base_snapshot else None,
|
||||
config_snapshot=agent_soul,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
)
|
||||
db.session.add(draft)
|
||||
db.session.flush()
|
||||
return draft
|
||||
|
||||
@classmethod
|
||||
def _save_agent_draft(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
draft_type: AgentConfigDraftType,
|
||||
account_id: str | None,
|
||||
agent_soul: AgentSoulConfig,
|
||||
account_id_for_audit: str,
|
||||
base_snapshot_id: str | None = None,
|
||||
) -> AgentConfigDraft:
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=draft_type,
|
||||
account_id=account_id,
|
||||
created_by=account_id_for_audit,
|
||||
)
|
||||
draft.config_snapshot = agent_soul
|
||||
if base_snapshot_id is not None:
|
||||
draft.base_snapshot_id = base_snapshot_id
|
||||
elif draft.base_snapshot_id is None:
|
||||
draft.base_snapshot_id = agent.active_config_snapshot_id
|
||||
draft.updated_by = account_id_for_audit
|
||||
if draft_type == AgentConfigDraftType.DRAFT and account_id is None:
|
||||
agent.active_config_is_published = False
|
||||
db.session.flush()
|
||||
return draft
|
||||
|
||||
@classmethod
|
||||
def _serialize_draft(cls, draft: AgentConfigDraft | None) -> dict[str, Any] | None:
|
||||
if draft is None:
|
||||
return None
|
||||
return {
|
||||
"id": draft.id,
|
||||
"agent_id": draft.agent_id,
|
||||
"draft_type": draft.draft_type.value,
|
||||
"account_id": draft.account_id,
|
||||
"base_snapshot_id": draft.base_snapshot_id,
|
||||
"created_by": draft.created_by,
|
||||
"updated_by": draft.updated_by,
|
||||
"created_at": to_timestamp(draft.created_at),
|
||||
"updated_at": to_timestamp(draft.updated_at),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_build_draft_state(cls, draft: AgentConfigDraft) -> dict[str, Any]:
|
||||
return {
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"draft": cls._serialize_draft(draft),
|
||||
"agent_soul": draft.config_snapshot_dict,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_draft_workflow(cls, *, tenant_id: str, app_id: str) -> Workflow:
|
||||
workflow = db.session.scalar(
|
||||
@@ -1471,6 +1892,12 @@ class AgentComposerService:
|
||||
"impact_summary": cls.calculate_impact(tenant_id=binding.tenant_id, current_snapshot_id=version.id)
|
||||
if version
|
||||
else None,
|
||||
"app_id": binding.app_id,
|
||||
"backing_app_id": agent.backing_app_id if agent else None,
|
||||
"hidden_app_backed": bool(agent and agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
|
||||
"chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages" if agent else None,
|
||||
"workflow_id": binding.workflow_id,
|
||||
"node_id": binding.node_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -1479,7 +1906,15 @@ class AgentComposerService:
|
||||
"id": agent.id,
|
||||
"name": agent.name,
|
||||
"description": agent.description,
|
||||
"role": agent.role,
|
||||
"icon_type": agent.icon_type,
|
||||
"icon": agent.icon,
|
||||
"icon_background": agent.icon_background,
|
||||
"scope": agent.scope.value,
|
||||
"source": agent.source.value,
|
||||
"app_id": agent.app_id,
|
||||
"backing_app_id": agent.backing_app_id or agent.app_id,
|
||||
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
|
||||
"status": agent.status.value,
|
||||
"active_config_snapshot_id": agent.active_config_snapshot_id,
|
||||
}
|
||||
|
||||
@@ -148,15 +148,15 @@ class ComposerConfigValidator:
|
||||
cls,
|
||||
payload: ComposerSavePayload,
|
||||
*,
|
||||
existing_dataset_ids: set[str] | None = None,
|
||||
existing_knowledge_set_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 §5.3/§5.4 soft findings — never block save.
|
||||
|
||||
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
|
||||
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
|
||||
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge-set
|
||||
mentions with a placeholder name (0522 consensus) instead of dropping or
|
||||
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
|
||||
deleted datasets surface as placeholders too.
|
||||
rejecting them. With ``existing_knowledge_set_ids`` provided, mentions
|
||||
that no longer exist in the current Agent Soul surface as placeholders too.
|
||||
"""
|
||||
warnings: list[dict[str, Any]] = []
|
||||
placeholders: list[dict[str, str]] = []
|
||||
@@ -188,7 +188,7 @@ class ComposerConfigValidator:
|
||||
resolved = resolver(mention)
|
||||
if mention.kind == MentionKind.KNOWLEDGE:
|
||||
dangling = resolved is None or (
|
||||
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
|
||||
existing_knowledge_set_ids is not None and mention.ref_id not in existing_knowledge_set_ids
|
||||
)
|
||||
if dangling:
|
||||
placeholders.append(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def list_agent_soul_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
|
||||
"""Return normalized unique knowledge dataset ids in config order.
|
||||
|
||||
Agent v2 knowledge dataset selection is owned by ``knowledge.sets``. This
|
||||
helper keeps composer, workflow validation, candidates, and runtime
|
||||
diagnostics aligned on the same normalization rules: strip whitespace, drop
|
||||
blanks, preserve first-seen order, and deduplicate.
|
||||
"""
|
||||
dataset_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for knowledge_set in agent_soul.knowledge.sets:
|
||||
for dataset in knowledge_set.datasets:
|
||||
dataset_id = (dataset.id or "").strip()
|
||||
if not dataset_id or dataset_id in seen:
|
||||
continue
|
||||
seen.add(dataset_id)
|
||||
dataset_ids.append(dataset_id)
|
||||
return dataset_ids
|
||||
|
||||
|
||||
def get_tenant_knowledge_dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
|
||||
"""Return tenant-scoped dataset rows for normalized knowledge dataset ids.
|
||||
|
||||
Knowledge ids come from user-editable config. Malformed ids can never match
|
||||
a dataset row, so they are treated as missing instead of breaking the
|
||||
UUID-typed dataset lookup.
|
||||
"""
|
||||
from services.dataset_service import DatasetService
|
||||
|
||||
valid_ids: list[str] = []
|
||||
for dataset_id in dataset_ids:
|
||||
try:
|
||||
UUID(dataset_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
valid_ids.append(dataset_id)
|
||||
|
||||
if not valid_ids:
|
||||
return {}
|
||||
|
||||
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
|
||||
return {str(row.id): row for row in rows}
|
||||
|
||||
|
||||
def list_missing_tenant_knowledge_dataset_ids(*, tenant_id: str, agent_soul: AgentSoulConfig | None) -> list[str]:
|
||||
"""Return normalized knowledge dataset ids missing from the tenant scope."""
|
||||
if agent_soul is None:
|
||||
return []
|
||||
|
||||
dataset_ids = list_agent_soul_knowledge_dataset_ids(agent_soul)
|
||||
if not dataset_ids:
|
||||
return []
|
||||
|
||||
rows = get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=dataset_ids)
|
||||
return [dataset_id for dataset_id in dataset_ids if dataset_id not in rows]
|
||||
@@ -1,23 +1,26 @@
|
||||
"""Prompt mention (slash-reference) serialization contract — ENG-616.
|
||||
"""Prompt mention and workflow-marker parsing helpers for Agent surfaces.
|
||||
|
||||
Slash-menu insertions are stored inline in the plain-string prompt as tokens:
|
||||
Slash-menu insertions are stored inline as mention tokens:
|
||||
|
||||
[§<kind>:<id>[:<label>]§]
|
||||
|
||||
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent
|
||||
runtime context. For prompt-owned entities that means Agent Soul lists such as
|
||||
``tools`` / ``knowledge.datasets`` / ``human.contacts`` and workflow job lists
|
||||
such as ``previous_node_output_refs`` / ``declared_outputs``. For drive-backed
|
||||
``skill`` / ``file`` mentions the field stores a URL-encoded drive key and is
|
||||
resolved against ``agent_drive_files`` at runtime. ``label`` is an optional
|
||||
plain-text fallback only. A single ``:`` separates all three fields; ``label``
|
||||
is the trailing remainder and may itself contain ``:``.
|
||||
Those tokens point at Agent-owned config such as Soul tools/knowledge/humans or
|
||||
workflow task config such as ``previous_node_output_refs`` / ``declared_outputs``.
|
||||
Runtime mention expansion is owned by the run-request builders.
|
||||
|
||||
The ``[§…§]`` wrapper uses the section sign ``§`` (U+00A7), which never appears
|
||||
in Dify template syntax (``{{var}}`` / ``{{#a.b#}}``) nor in normal prompt text,
|
||||
so these tokens can never collide with the existing template parsers. Runtime
|
||||
expansion (and the final scrub that guarantees no internal marker ever reaches
|
||||
the model) is owned by the run-request builders.
|
||||
Workflow Agent tasks also carry frontend workflow variable markers:
|
||||
|
||||
{{#<node-id>.<output>[.<child>...]#}}
|
||||
|
||||
Those frontend markers are a separate path from slash-reference expansion. They
|
||||
are parsed here only to derive ``previous_node_output_refs`` from the current
|
||||
task text. The markers remain literal in the workflow task prompt, while their
|
||||
resolved values appear under the workflow context prompt's ``Previous node
|
||||
outputs:`` section. Legacy ``[§node_output:...§]`` mention syntax is not part
|
||||
of that derivation path.
|
||||
|
||||
Frontend output blocks still accept a legacy bare ``§output:...§`` form during
|
||||
migrations, so the mention parser keeps that alias for output mentions only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -47,13 +50,16 @@ class MentionKind(StrEnum):
|
||||
|
||||
|
||||
MENTION_PATTERN = re.compile(
|
||||
r"\[§(skill|file|tool|cli_tool|knowledge|human|node_output|output):([^:§]+?)(?::([^§]*?))?§\]"
|
||||
r"(?:\[§(?P<bracket_kind>skill|file|tool|cli_tool|knowledge|human|node_output|output):"
|
||||
r"(?P<bracket_id>[^:§]+?)(?::(?P<bracket_label>[^§]*?))?§\])"
|
||||
r"|(?:§(?P<legacy_kind>output):(?P<legacy_id>[^:§]+?)(?::(?P<legacy_label>[^§]*?))?§)"
|
||||
)
|
||||
# Anything mention-shaped (``[§word:…§]``) that the strict pattern did not consume
|
||||
# — unknown kinds, malformed bodies. The ``§`` wrapper + a kind-word + ``:``
|
||||
# requirement keeps legacy ``{{#histories#}}`` / ``{{var}}`` template forms and
|
||||
# ordinary bracketed text out of scope.
|
||||
_RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\]")
|
||||
WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}")
|
||||
|
||||
MAX_MENTIONS_PER_PROMPT = 200
|
||||
# Drive keys are validated up to 512 Unicode code points before URL encoding.
|
||||
@@ -71,7 +77,8 @@ MAX_MENTION_LABEL_LENGTH = 255
|
||||
ALL_PROVIDER_TOOLS_SUFFIX = "*"
|
||||
|
||||
# Per-surface allowlists (design §2.4): the soul prompt may only reference
|
||||
# soul-owned entities; the workflow job prompt may only reference run-scoped ones.
|
||||
# soul-owned entities; the persisted workflow task prompt may only reference
|
||||
# run-scoped ones.
|
||||
SOUL_PROMPT_ALLOWED_KINDS = frozenset(
|
||||
{
|
||||
MentionKind.SKILL,
|
||||
@@ -83,6 +90,9 @@ SOUL_PROMPT_ALLOWED_KINDS = frozenset(
|
||||
}
|
||||
)
|
||||
NODE_JOB_PROMPT_ALLOWED_KINDS = frozenset({MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN})
|
||||
WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES = frozenset(
|
||||
{"sys", "env", "conversation", "rag", "current", "last_run", "error_message", "$output"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -100,18 +110,24 @@ class PromptMention:
|
||||
MentionResolver = Callable[[PromptMention], str | None]
|
||||
|
||||
|
||||
def _mention_groups(match: re.Match[str]) -> tuple[str, str, str | None]:
|
||||
kind = match.group("bracket_kind") or match.group("legacy_kind")
|
||||
ref_id = match.group("bracket_id") or match.group("legacy_id")
|
||||
label = match.group("bracket_label") or match.group("legacy_label")
|
||||
return kind, ref_id, label
|
||||
|
||||
|
||||
def parse_prompt_mentions(prompt: str) -> list[PromptMention]:
|
||||
"""Extract well-formed mentions. Oversized id/label tokens are skipped here
|
||||
(treated as malformed) — the runtime scrub still degrades them safely."""
|
||||
mentions: list[PromptMention] = []
|
||||
for match in MENTION_PATTERN.finditer(prompt or ""):
|
||||
ref_id = match.group(2)
|
||||
label = match.group(3)
|
||||
kind, ref_id, label = _mention_groups(match)
|
||||
if len(ref_id) > MAX_MENTION_REF_ID_LENGTH or (label is not None and len(label) > MAX_MENTION_LABEL_LENGTH):
|
||||
continue
|
||||
mentions.append(
|
||||
PromptMention(
|
||||
kind=MentionKind(match.group(1)),
|
||||
kind=MentionKind(kind),
|
||||
ref_id=ref_id,
|
||||
label=label or None,
|
||||
start=match.start(),
|
||||
@@ -130,13 +146,13 @@ def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
|
||||
return prompt
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
ref_id = match.group(2)
|
||||
label = match.group(3) or None
|
||||
kind, ref_id, label = _mention_groups(match)
|
||||
label = label or None
|
||||
fallback = (label or ref_id)[:MAX_MENTION_LABEL_LENGTH]
|
||||
if len(ref_id) > MAX_MENTION_REF_ID_LENGTH or (label is not None and len(label) > MAX_MENTION_LABEL_LENGTH):
|
||||
return fallback
|
||||
mention = PromptMention(
|
||||
kind=MentionKind(match.group(1)),
|
||||
kind=MentionKind(kind),
|
||||
ref_id=ref_id,
|
||||
label=label,
|
||||
start=match.start(),
|
||||
@@ -161,6 +177,48 @@ def find_malformed_mention_markers(prompt: str) -> list[str]:
|
||||
return [match.group(0) for match in _RESIDUAL_MENTION_PATTERN.finditer(prompt) if match.span() not in parsed_spans]
|
||||
|
||||
|
||||
def extract_workflow_variable_selectors(prompt: str) -> list[tuple[str, ...]]:
|
||||
"""Extract ``{{#node.output#}}``-style selectors from workflow prompts."""
|
||||
selectors: list[tuple[str, ...]] = []
|
||||
for match in WORKFLOW_VARIABLE_PATTERN.finditer(prompt or ""):
|
||||
parts = tuple(part.strip() for part in match.group(1).split(".") if part.strip())
|
||||
if len(parts) >= 2:
|
||||
selectors.append(parts)
|
||||
return selectors
|
||||
|
||||
|
||||
def extract_workflow_node_output_selectors(prompt: str) -> list[tuple[str, ...]]:
|
||||
"""Extract previous-node selectors from frontend workflow variable markers.
|
||||
|
||||
Reserved Dify namespaces such as ``sys`` are excluded because they are not
|
||||
previous nodes.
|
||||
"""
|
||||
selectors: list[tuple[str, ...]] = []
|
||||
seen: set[tuple[str, ...]] = set()
|
||||
for selector in extract_workflow_variable_selectors(prompt):
|
||||
if selector[0] in WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES:
|
||||
continue
|
||||
if selector in seen:
|
||||
continue
|
||||
selectors.append(selector)
|
||||
seen.add(selector)
|
||||
return selectors
|
||||
|
||||
|
||||
def workflow_previous_node_output_refs_from_selectors(
|
||||
selectors: list[tuple[str, ...]],
|
||||
) -> list[WorkflowPreviousNodeOutputRef]:
|
||||
"""Materialize persisted previous-node refs from parsed frontend selectors."""
|
||||
return [
|
||||
WorkflowPreviousNodeOutputRef(
|
||||
selector=list(selector),
|
||||
node_id=selector[0],
|
||||
output=selector[1],
|
||||
)
|
||||
for selector in selectors
|
||||
]
|
||||
|
||||
|
||||
def scrub_mention_markers(text: str) -> str:
|
||||
"""Degrade any residual mention-shaped ``[§kind:…§]`` marker to readable text."""
|
||||
|
||||
@@ -211,9 +269,9 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
|
||||
if mention.ref_id in (cli_tool.id, cli_tool.name):
|
||||
return cli_tool.name or cli_tool.id
|
||||
case MentionKind.KNOWLEDGE:
|
||||
for dataset in agent_soul.knowledge.datasets:
|
||||
if mention.ref_id == dataset.id:
|
||||
return dataset.name or dataset.id
|
||||
for knowledge_set in agent_soul.knowledge.sets:
|
||||
if mention.ref_id == knowledge_set.id:
|
||||
return knowledge_set.name or knowledge_set.id
|
||||
case MentionKind.HUMAN:
|
||||
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
|
||||
case _:
|
||||
@@ -224,14 +282,17 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
|
||||
|
||||
|
||||
def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionResolver:
|
||||
"""Resolve job-surface mentions. ``node_output`` expands to the stored
|
||||
reference name only — values stay in the Workflow context block (design §4.2)."""
|
||||
"""Resolve persisted workflow task prompt mentions.
|
||||
|
||||
``node_output`` expands to the stored reference name only; values stay in
|
||||
the workflow context block for the run-scoped ``user_prompt``.
|
||||
"""
|
||||
|
||||
def _resolve(mention: PromptMention) -> str | None:
|
||||
match mention.kind:
|
||||
case MentionKind.NODE_OUTPUT:
|
||||
for ref in node_job.previous_node_output_refs:
|
||||
selector = _selector_from_ref(ref)
|
||||
selector = normalize_previous_node_output_selector(ref)
|
||||
if selector and f"{selector[0]}.{selector[1]}" == mention.ref_id:
|
||||
return ref.name or mention.label or mention.ref_id
|
||||
case MentionKind.OUTPUT:
|
||||
@@ -256,14 +317,27 @@ def _resolve_human_contact(contacts: list[AgentHumanContactConfig], ref_id: str)
|
||||
return None
|
||||
|
||||
|
||||
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] | None:
|
||||
def normalize_previous_node_output_selector(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, ...] | None:
|
||||
"""Return the canonical previous-node selector for a persisted ref.
|
||||
|
||||
Explicit selector arrays win and must contain at least two string parts. The
|
||||
legacy field form falls back to ``node_id`` plus the first available output
|
||||
field. Callers that only need node/output identity should compare the first
|
||||
two returned items.
|
||||
"""
|
||||
for candidate in (ref.selector, ref.variable_selector, ref.value_selector):
|
||||
if isinstance(candidate, list) and len(candidate) >= 2:
|
||||
return str(candidate[0]), str(candidate[1])
|
||||
if ref.node_id:
|
||||
output = ref.output or ref.variable or ref.key
|
||||
if output:
|
||||
return ref.node_id, output
|
||||
selector_parts: list[str] = []
|
||||
for item in candidate:
|
||||
if not isinstance(item, str):
|
||||
break
|
||||
selector_parts.append(item)
|
||||
if len(selector_parts) == len(candidate):
|
||||
return tuple(selector_parts)
|
||||
node_id = ref.get("node_id")
|
||||
output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key")
|
||||
if isinstance(node_id, str) and isinstance(output_name, str):
|
||||
return node_id, output_name
|
||||
return None
|
||||
|
||||
|
||||
@@ -275,13 +349,18 @@ __all__ = [
|
||||
"MENTION_PATTERN",
|
||||
"NODE_JOB_PROMPT_ALLOWED_KINDS",
|
||||
"SOUL_PROMPT_ALLOWED_KINDS",
|
||||
"WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES",
|
||||
"MentionKind",
|
||||
"MentionResolver",
|
||||
"PromptMention",
|
||||
"build_node_job_mention_resolver",
|
||||
"build_soul_mention_resolver",
|
||||
"expand_prompt_mentions",
|
||||
"extract_workflow_node_output_selectors",
|
||||
"extract_workflow_variable_selectors",
|
||||
"find_malformed_mention_markers",
|
||||
"normalize_previous_node_output_selector",
|
||||
"parse_prompt_mentions",
|
||||
"scrub_mention_markers",
|
||||
"workflow_previous_node_output_refs_from_selectors",
|
||||
]
|
||||
|
||||
@@ -3,11 +3,14 @@ from typing import Any, TypedDict
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from constants.model_template import default_app_templates
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import to_timestamp
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -21,7 +24,7 @@ from models.agent import (
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import AppStatus, ConversationFromSource, ConversationStatus
|
||||
from models.model import App, AppMode, Conversation, IconType
|
||||
from models.model import App, AppMode, AppModelConfig, Conversation, IconType
|
||||
from models.workflow import Workflow
|
||||
from services.agent.agent_soul_state import agent_soul_has_model
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
@@ -83,7 +86,7 @@ class AgentRosterService:
|
||||
agent: Agent,
|
||||
active_version: AgentConfigSnapshot | None = None,
|
||||
published_references: list[AgentReferencingWorkflow] | None = None,
|
||||
active_config_is_published: bool = False,
|
||||
active_config_is_published: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
published_references = published_references or []
|
||||
return {
|
||||
@@ -98,12 +101,16 @@ class AgentRosterService:
|
||||
"scope": agent.scope.value,
|
||||
"source": agent.source.value,
|
||||
"app_id": agent.app_id,
|
||||
"backing_app_id": agent.backing_app_id,
|
||||
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
|
||||
"debug_conversation_id": None,
|
||||
"workflow_id": agent.workflow_id,
|
||||
"workflow_node_id": agent.workflow_node_id,
|
||||
"active_config_snapshot_id": agent.active_config_snapshot_id,
|
||||
"active_config_snapshot": AgentRosterService.serialize_version(active_version) if active_version else None,
|
||||
"active_config_is_published": active_config_is_published,
|
||||
"active_config_is_published": agent.active_config_is_published
|
||||
if active_config_is_published is None
|
||||
else active_config_is_published,
|
||||
"status": agent.status.value,
|
||||
"created_by": agent.created_by,
|
||||
"updated_by": agent.updated_by,
|
||||
@@ -321,6 +328,7 @@ class AgentRosterService:
|
||||
self._session.add(revision)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
|
||||
try:
|
||||
self._session.commit()
|
||||
@@ -363,6 +371,7 @@ class AgentRosterService:
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id=app_id,
|
||||
backing_app_id=app_id,
|
||||
created_by=account_id,
|
||||
updated_by=account_id,
|
||||
)
|
||||
@@ -394,10 +403,58 @@ class AgentRosterService:
|
||||
self._session.add(revision)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(AgentSoulConfig())
|
||||
agent.active_config_is_published = False
|
||||
self._session.flush()
|
||||
self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id)
|
||||
return agent
|
||||
|
||||
def create_hidden_backing_app_for_workflow_agent(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str | None,
|
||||
name: str,
|
||||
description: str = "",
|
||||
icon_type: Any = None,
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
) -> App:
|
||||
"""Create an internal Agent App used only to back a workflow-only Agent.
|
||||
|
||||
This deliberately bypasses AppService.create_app because that public
|
||||
creation path also creates a roster Agent. Inline Agents need App runtime
|
||||
infrastructure for chat/logs/monitoring, but must stay hidden from the
|
||||
workspace Agent Roster until explicitly saved to roster.
|
||||
"""
|
||||
|
||||
app_template = dict(default_app_templates[AppMode.AGENT]["app"])
|
||||
app = App(**app_template)
|
||||
app.name = name
|
||||
app.description = description or ""
|
||||
app.mode = AppMode.AGENT
|
||||
normalized_icon_type = self._normalize_app_icon_type(icon_type)
|
||||
app.icon_type = IconType(normalized_icon_type) if normalized_icon_type else IconType.EMOJI
|
||||
app.icon = icon
|
||||
app.icon_background = icon_background
|
||||
app.tenant_id = tenant_id
|
||||
app.enable_site = False
|
||||
app.enable_api = False
|
||||
app.api_rph = 0
|
||||
app.api_rpm = 0
|
||||
app.max_active_requests = None
|
||||
app.created_by = account_id
|
||||
app.maintainer = account_id
|
||||
app.updated_by = account_id
|
||||
self._session.add(app)
|
||||
self._session.flush()
|
||||
|
||||
app_model_config = AppModelConfig(app_id=app.id, created_by=account_id, updated_by=account_id)
|
||||
self._session.add(app_model_config)
|
||||
self._session.flush()
|
||||
app.app_model_config_id = app_model_config.id
|
||||
self._session.flush()
|
||||
return app
|
||||
|
||||
def _create_agent_app_debug_conversation(self, *, app_id: str, account_id: str) -> str:
|
||||
"""Create one console debug conversation for an Agent App editor."""
|
||||
|
||||
@@ -423,8 +480,31 @@ class AgentRosterService:
|
||||
self._session.flush()
|
||||
return conversation.id
|
||||
|
||||
@staticmethod
|
||||
def runtime_backing_app_id(agent: Agent) -> str | None:
|
||||
"""Return the App id that backs Agent runtime chat/log/monitoring."""
|
||||
|
||||
return agent.backing_app_id or agent.app_id
|
||||
|
||||
def _ensure_workflow_agent_backing_app(self, *, agent: Agent, account_id: str | None) -> str | None:
|
||||
if agent.scope != AgentScope.WORKFLOW_ONLY or agent.backing_app_id:
|
||||
return self.runtime_backing_app_id(agent)
|
||||
backing_app = self.create_hidden_backing_app_for_workflow_agent(
|
||||
tenant_id=agent.tenant_id,
|
||||
account_id=account_id or agent.updated_by or agent.created_by,
|
||||
name=agent.name,
|
||||
description=agent.description,
|
||||
icon_type=agent.icon_type,
|
||||
icon=agent.icon,
|
||||
icon_background=agent.icon_background,
|
||||
)
|
||||
agent.backing_app_id = backing_app.id
|
||||
self._session.flush()
|
||||
return backing_app.id
|
||||
|
||||
def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str:
|
||||
if not agent.app_id:
|
||||
backing_app_id = self._ensure_workflow_agent_backing_app(agent=agent, account_id=account_id)
|
||||
if not backing_app_id:
|
||||
raise AgentNotFoundError()
|
||||
|
||||
mapping = self._session.scalar(
|
||||
@@ -438,7 +518,7 @@ class AgentRosterService:
|
||||
conversation_id = self._session.scalar(
|
||||
select(Conversation.id).where(
|
||||
Conversation.id == mapping.conversation_id,
|
||||
Conversation.app_id == agent.app_id,
|
||||
Conversation.app_id == backing_app_id,
|
||||
Conversation.from_source == ConversationFromSource.CONSOLE,
|
||||
Conversation.from_account_id == account_id,
|
||||
Conversation.is_deleted.is_(False),
|
||||
@@ -448,21 +528,22 @@ class AgentRosterService:
|
||||
return conversation_id
|
||||
|
||||
mapping.conversation_id = self._create_agent_app_debug_conversation(
|
||||
app_id=agent.app_id,
|
||||
app_id=backing_app_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
mapping.app_id = backing_app_id
|
||||
self._session.flush()
|
||||
return mapping.conversation_id
|
||||
|
||||
conversation_id = self._create_agent_app_debug_conversation(
|
||||
app_id=agent.app_id,
|
||||
app_id=backing_app_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
self._session.add(
|
||||
AgentDebugConversation(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
app_id=agent.app_id,
|
||||
app_id=backing_app_id,
|
||||
account_id=account_id,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
@@ -479,8 +560,6 @@ class AgentRosterService:
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
@@ -501,16 +580,20 @@ class AgentRosterService:
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
if agent is None or not agent.app_id:
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
backing_app_id = self._ensure_workflow_agent_backing_app(
|
||||
agent=agent,
|
||||
account_id=agent.updated_by or agent.created_by,
|
||||
)
|
||||
if not backing_app_id:
|
||||
raise AgentNotFoundError()
|
||||
|
||||
conversation_id = self._create_agent_app_debug_conversation(
|
||||
app_id=agent.app_id,
|
||||
app_id=backing_app_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
mapping = self._session.scalar(
|
||||
@@ -525,13 +608,13 @@ class AgentRosterService:
|
||||
AgentDebugConversation(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
app_id=agent.app_id,
|
||||
app_id=backing_app_id,
|
||||
account_id=account_id,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
mapping.app_id = agent.app_id
|
||||
mapping.app_id = backing_app_id
|
||||
mapping.conversation_id = conversation_id
|
||||
self._session.flush()
|
||||
if commit:
|
||||
@@ -546,11 +629,7 @@ class AgentRosterService:
|
||||
conversation_ids_by_agent_id: dict[str, str] = {}
|
||||
changed = False
|
||||
for agent in agents:
|
||||
if (
|
||||
agent.tenant_id != tenant_id
|
||||
or agent.scope != AgentScope.ROSTER
|
||||
or agent.source != AgentSource.AGENT_APP
|
||||
):
|
||||
if agent.tenant_id != tenant_id or agent.status != AgentStatus.ACTIVE:
|
||||
continue
|
||||
conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation(
|
||||
agent=agent,
|
||||
@@ -574,7 +653,7 @@ class AgentRosterService:
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
return {agent.app_id: agent for agent in agents if agent.app_id}
|
||||
return {agent.app_id: agent for agent in agents if agent.app_id and agent.id}
|
||||
|
||||
def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None:
|
||||
"""Return the roster Agent that backs the given Agent App, if any."""
|
||||
@@ -625,6 +704,59 @@ class AgentRosterService:
|
||||
raise AgentNotFoundError()
|
||||
return app
|
||||
|
||||
def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App:
|
||||
"""Resolve the App that backs an Agent runtime surface.
|
||||
|
||||
Roster Agents use their public Agent App. Workflow-only Agents use a
|
||||
hidden Agent App stored in ``backing_app_id`` so console chat/logs can
|
||||
reuse the app runtime without exposing the resource in workspace app
|
||||
lists.
|
||||
"""
|
||||
|
||||
agent = self._session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
or_(
|
||||
and_(Agent.scope == AgentScope.ROSTER, Agent.source == AgentSource.AGENT_APP),
|
||||
and_(
|
||||
Agent.scope == AgentScope.WORKFLOW_ONLY,
|
||||
Agent.source == AgentSource.WORKFLOW,
|
||||
Agent.workflow_id.is_not(None),
|
||||
Agent.workflow_node_id.is_not(None),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
should_commit_backing_app = agent.scope == AgentScope.WORKFLOW_ONLY and not agent.backing_app_id
|
||||
backing_app_id = self._ensure_workflow_agent_backing_app(
|
||||
agent=agent,
|
||||
account_id=agent.updated_by or agent.created_by,
|
||||
)
|
||||
if not backing_app_id:
|
||||
raise AgentNotFoundError()
|
||||
if should_commit_backing_app:
|
||||
self._session.commit()
|
||||
|
||||
app = self._session.scalar(
|
||||
select(App)
|
||||
.where(
|
||||
App.tenant_id == tenant_id,
|
||||
App.id == backing_app_id,
|
||||
App.mode == AppMode.AGENT,
|
||||
App.status == AppStatus.NORMAL,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if app is None:
|
||||
raise AgentNotFoundError()
|
||||
return app
|
||||
|
||||
def duplicate_agent_app(
|
||||
self,
|
||||
*,
|
||||
@@ -692,10 +824,10 @@ class AgentRosterService:
|
||||
return target_app
|
||||
|
||||
@staticmethod
|
||||
def _normalize_app_icon_type(icon_type: IconType | str | None) -> str | None:
|
||||
def _normalize_app_icon_type(icon_type: Any | None) -> str | None:
|
||||
if icon_type is None:
|
||||
return None
|
||||
if isinstance(icon_type, IconType):
|
||||
if isinstance(icon_type, IconType) or hasattr(icon_type, "value"):
|
||||
return icon_type.value
|
||||
return icon_type
|
||||
|
||||
@@ -737,6 +869,7 @@ class AgentRosterService:
|
||||
target_version.version_note = source_version.version_note
|
||||
target_version.created_by = account_id
|
||||
target_agent.active_config_has_model = agent_soul_has_model(target_version.config_snapshot)
|
||||
target_agent.active_config_is_published = source_agent.active_config_is_published
|
||||
target_agent.updated_by = account_id
|
||||
|
||||
def _next_duplicate_agent_name(self, *, tenant_id: str, base_name: str) -> str:
|
||||
@@ -836,6 +969,7 @@ class AgentRosterService:
|
||||
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
|
||||
if agent.source == AgentSource.AGENT_APP:
|
||||
return {
|
||||
AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
@@ -849,16 +983,27 @@ class AgentRosterService:
|
||||
}
|
||||
|
||||
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
|
||||
"""Return whether the Agent's current active snapshot is a visible published version."""
|
||||
"""Return whether the normal shared draft has been published into the active snapshot."""
|
||||
return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get(
|
||||
agent.id,
|
||||
False,
|
||||
)
|
||||
|
||||
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
|
||||
"""Return publish-state flags for the active config snapshots of the given Agents."""
|
||||
published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents)
|
||||
return {agent.id: agent.id in published_agent_ids for agent in agents}
|
||||
"""Return each Agent's stored normal-draft publish state.
|
||||
|
||||
The flag is maintained by write paths against the normal shared draft:
|
||||
saves compare the draft content with the active snapshot, while publish
|
||||
and version creation paths mark the new active snapshot clean.
|
||||
User-scoped debug drafts intentionally do not affect this state.
|
||||
"""
|
||||
agents = [agent for agent in agents if agent.id]
|
||||
if not agents:
|
||||
return {}
|
||||
|
||||
return {
|
||||
agent.id: bool(agent.active_config_snapshot_id and agent.active_config_is_published) for agent in agents
|
||||
}
|
||||
|
||||
def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
|
||||
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
|
||||
@@ -957,26 +1102,38 @@ class AgentRosterService:
|
||||
raise AgentVersionNotFoundError()
|
||||
|
||||
version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
|
||||
if agent.active_config_snapshot_id == version.id:
|
||||
return {"result": "success", "active_config_snapshot_id": version.id}
|
||||
|
||||
previous_snapshot_id = agent.active_config_snapshot_id
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(version.config_snapshot)
|
||||
agent.updated_by = account_id
|
||||
self._session.add(
|
||||
AgentConfigRevision(
|
||||
draft = self._session.scalar(
|
||||
select(AgentConfigDraft)
|
||||
.where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent_id,
|
||||
AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT,
|
||||
AgentConfigDraft.account_id.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if draft is None:
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
previous_snapshot_id=previous_snapshot_id,
|
||||
current_snapshot_id=version.id,
|
||||
revision=self._next_revision(tenant_id=tenant_id, agent_id=agent_id),
|
||||
operation=AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
created_by=account_id,
|
||||
)
|
||||
)
|
||||
self._session.add(draft)
|
||||
draft.base_snapshot_id = version.id
|
||||
draft.config_snapshot = AgentSoulConfig.model_validate(version.config_snapshot_dict)
|
||||
draft.updated_by = account_id
|
||||
agent.active_config_is_published = version.id == agent.active_config_snapshot_id
|
||||
agent.updated_by = account_id
|
||||
self._session.commit()
|
||||
return {"result": "success", "active_config_snapshot_id": version.id}
|
||||
return {
|
||||
"result": "success",
|
||||
"active_config_snapshot_id": agent.active_config_snapshot_id or version.id,
|
||||
"draft_config_id": draft.id,
|
||||
"restored_version_id": version.id,
|
||||
}
|
||||
|
||||
def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent:
|
||||
stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id)
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Validate + extract metadata from an uploaded Skill package (ENG-370).
|
||||
"""Validate and normalize uploaded Skill packages for drive standardization.
|
||||
|
||||
A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entry
|
||||
file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``,
|
||||
followed by markdown instructions). This service validates the archive (extension,
|
||||
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields) and
|
||||
extracts a manifest consumed by drive standardization.
|
||||
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields),
|
||||
normalizes retained member paths relative to the selected skill root, rebuilds
|
||||
canonical archive bytes, and returns normalized metadata together with the
|
||||
archive-root ``SKILL.md`` bytes.
|
||||
|
||||
It does NOT execute or load the skill — the agent backend owns execution. It also
|
||||
does not persist anything into Agent Soul or bind anything to config versions;
|
||||
``SkillStandardizeService`` consumes the manifest and commits the canonical drive
|
||||
rows instead.
|
||||
``SkillStandardizeService`` consumes the normalized package and commits the
|
||||
canonical drive rows instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,6 +21,7 @@ import io
|
||||
import posixpath
|
||||
import re
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
@@ -58,10 +61,69 @@ class SkillManifest(BaseModel):
|
||||
hash: str # sha256 of the archive bytes
|
||||
|
||||
|
||||
class SkillPackageService:
|
||||
"""Validate Skill archives and extract their manifest."""
|
||||
class NormalizedSkillPackage(BaseModel):
|
||||
"""Canonical skill package bytes and metadata ready to store in agent drive."""
|
||||
|
||||
def validate_and_extract(self, *, content: bytes, filename: str) -> SkillManifest:
|
||||
manifest: SkillManifest
|
||||
archive_bytes: bytes
|
||||
skill_md_bytes: bytes
|
||||
strip_prefix: str | None
|
||||
|
||||
|
||||
class SkillPackageService:
|
||||
"""Validate Skill archives and produce the normalized package stored in drive."""
|
||||
|
||||
def validate_and_normalize(self, *, content: bytes, filename: str) -> NormalizedSkillPackage:
|
||||
"""Return the canonical drive package for an uploaded skill archive.
|
||||
|
||||
The shallowest ``SKILL.md`` defines the skill root. When exactly one
|
||||
depth-2 ``<folder>/SKILL.md`` exists, normalization strips that top-level
|
||||
folder and silently discards all members outside it, including nested
|
||||
foreign paths. When that unique depth-2 condition does not apply, files
|
||||
outside the selected skill root still raise ``files_outside_skill_root``.
|
||||
The returned manifest is normalized to archive-root ``SKILL.md`` and its
|
||||
hash describes the rebuilt archive bytes. Member read/decompression
|
||||
failures while consuming the archive are mapped to ``invalid_archive``.
|
||||
"""
|
||||
archive = self._open_archive(content=content, filename=filename)
|
||||
with archive:
|
||||
members = self._collect_file_members(archive)
|
||||
member_paths = [safe_path for _, safe_path in members]
|
||||
entry_path = self._find_skill_md(member_paths)
|
||||
strip_prefix = self._skill_root_prefix(entry_path)
|
||||
normalized_members = self._normalize_members(
|
||||
members=members,
|
||||
skill_root_prefix=strip_prefix,
|
||||
ignore_outside_selected_root=self._can_strip_single_top_level_folder(
|
||||
paths=member_paths, entry_path=entry_path
|
||||
),
|
||||
)
|
||||
skill_md_member = normalized_members[_SKILL_MD_NAME]
|
||||
self._validate_skill_md_size(skill_md_member)
|
||||
skill_md_bytes = self._read_member_bytes_from_archive(archive, member_info=skill_md_member)
|
||||
skill_md = self._decode_skill_md(skill_md_bytes)
|
||||
normalized_archive_bytes = self._build_normalized_archive(
|
||||
archive=archive, normalized_members=normalized_members
|
||||
)
|
||||
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
|
||||
|
||||
name, description = self._parse_skill_md(skill_md)
|
||||
manifest = SkillManifest(
|
||||
name=name,
|
||||
description=description,
|
||||
entry_path=_SKILL_MD_NAME,
|
||||
files=sorted(normalized_members),
|
||||
size=normalized_size,
|
||||
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
|
||||
)
|
||||
return NormalizedSkillPackage(
|
||||
manifest=manifest,
|
||||
archive_bytes=normalized_archive_bytes,
|
||||
skill_md_bytes=skill_md_bytes,
|
||||
strip_prefix=strip_prefix,
|
||||
)
|
||||
|
||||
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
|
||||
self._check_extension(filename)
|
||||
if not content:
|
||||
raise SkillPackageError("empty_archive", "skill archive is empty", status_code=400)
|
||||
@@ -69,52 +131,90 @@ class SkillPackageService:
|
||||
raise SkillPackageError("archive_too_large", "skill archive exceeds size limit", status_code=400)
|
||||
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(content))
|
||||
return zipfile.ZipFile(io.BytesIO(content))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
|
||||
|
||||
with archive:
|
||||
infos = [info for info in archive.infolist() if not info.is_dir()]
|
||||
if len(infos) > _MAX_ENTRIES:
|
||||
raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400)
|
||||
def _collect_file_members(self, archive: zipfile.ZipFile) -> list[tuple[zipfile.ZipInfo, str]]:
|
||||
infos = [info for info in archive.infolist() if not info.is_dir()]
|
||||
if len(infos) > _MAX_ENTRIES:
|
||||
raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400)
|
||||
|
||||
safe_paths: list[str] = []
|
||||
total_uncompressed = 0
|
||||
for info in infos:
|
||||
safe_paths.append(self._safe_member_path(info.filename))
|
||||
total_uncompressed += max(info.file_size, 0)
|
||||
if total_uncompressed > _MAX_UNCOMPRESSED_BYTES:
|
||||
raise SkillPackageError(
|
||||
"archive_too_large", "skill archive uncompressed size exceeds limit", status_code=400
|
||||
)
|
||||
|
||||
entry_path = self._find_skill_md(safe_paths)
|
||||
skill_md = self._read_skill_md(archive, entry_path)
|
||||
|
||||
name, description = self._parse_skill_md(skill_md)
|
||||
return SkillManifest(
|
||||
name=name,
|
||||
description=description,
|
||||
entry_path=entry_path,
|
||||
files=sorted(safe_paths),
|
||||
size=total_uncompressed,
|
||||
hash=hashlib.sha256(content).hexdigest(),
|
||||
)
|
||||
|
||||
def read_member_bytes(self, *, content: bytes, member_path: str) -> bytes:
|
||||
"""Read a single archive member's bytes (used by standardization, ENG-594)."""
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(content))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
|
||||
with archive:
|
||||
member = next(
|
||||
(info for info in archive.infolist() if posixpath.normpath(info.filename) == member_path),
|
||||
None,
|
||||
members: list[tuple[zipfile.ZipInfo, str]] = []
|
||||
total_uncompressed = 0
|
||||
for info in infos:
|
||||
members.append((info, self._safe_member_path(info.filename)))
|
||||
total_uncompressed += max(info.file_size, 0)
|
||||
if total_uncompressed > _MAX_UNCOMPRESSED_BYTES:
|
||||
raise SkillPackageError(
|
||||
"archive_too_large",
|
||||
"skill archive uncompressed size exceeds limit",
|
||||
status_code=400,
|
||||
)
|
||||
if member is None:
|
||||
raise SkillPackageError("member_not_found", f"{member_path} not found in archive", status_code=400)
|
||||
return archive.read(member)
|
||||
return members
|
||||
|
||||
@staticmethod
|
||||
def _skill_root_prefix(entry_path: str) -> str | None:
|
||||
skill_root = posixpath.dirname(entry_path)
|
||||
if not skill_root:
|
||||
return None
|
||||
return f"{skill_root}/"
|
||||
|
||||
def _normalize_members(
|
||||
self,
|
||||
*,
|
||||
members: list[tuple[zipfile.ZipInfo, str]],
|
||||
skill_root_prefix: str | None,
|
||||
ignore_outside_selected_root: bool = False,
|
||||
) -> dict[str, zipfile.ZipInfo]:
|
||||
normalized_members: dict[str, zipfile.ZipInfo] = {}
|
||||
for info, safe_path in members:
|
||||
if skill_root_prefix is not None:
|
||||
if not safe_path.startswith(skill_root_prefix):
|
||||
if ignore_outside_selected_root:
|
||||
continue
|
||||
raise SkillPackageError(
|
||||
"files_outside_skill_root",
|
||||
"skill archive contains files outside the selected skill root",
|
||||
status_code=400,
|
||||
)
|
||||
normalized_path = safe_path.removeprefix(skill_root_prefix)
|
||||
else:
|
||||
normalized_path = safe_path
|
||||
|
||||
if (
|
||||
not normalized_path
|
||||
or normalized_path in {".", ".."}
|
||||
or normalized_path.startswith("/")
|
||||
or "\\" in normalized_path
|
||||
):
|
||||
raise SkillPackageError("unsafe_path", "skill archive contains an unsafe path", status_code=400)
|
||||
if normalized_path in normalized_members:
|
||||
raise SkillPackageError(
|
||||
"duplicate_member_path",
|
||||
"skill archive contains duplicate normalized paths",
|
||||
status_code=400,
|
||||
)
|
||||
normalized_members[normalized_path] = info
|
||||
|
||||
if _SKILL_MD_NAME not in normalized_members:
|
||||
raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400)
|
||||
return normalized_members
|
||||
|
||||
def _build_normalized_archive(
|
||||
self,
|
||||
*,
|
||||
archive: zipfile.ZipFile,
|
||||
normalized_members: dict[str, zipfile.ZipInfo],
|
||||
) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as normalized_archive:
|
||||
for normalized_path in sorted(normalized_members):
|
||||
normalized_archive.writestr(
|
||||
normalized_path,
|
||||
self._read_member_bytes_from_archive(archive, member_info=normalized_members[normalized_path]),
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _check_extension(filename: str) -> None:
|
||||
@@ -145,17 +245,26 @@ class SkillPackageService:
|
||||
return min(candidates, key=lambda p: (p.count("/"), len(p)))
|
||||
|
||||
@staticmethod
|
||||
def _read_skill_md(archive: zipfile.ZipFile, entry_path: str) -> str:
|
||||
# Look the member up by its original name (normpath may differ from the stored name).
|
||||
member = next(
|
||||
(info for info in archive.infolist() if posixpath.normpath(info.filename) == entry_path),
|
||||
None,
|
||||
)
|
||||
if member is None:
|
||||
raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400)
|
||||
if member.file_size > _MAX_SKILL_MD_BYTES:
|
||||
def _can_strip_single_top_level_folder(*, paths: list[str], entry_path: str) -> bool:
|
||||
if entry_path.count("/") != 1:
|
||||
return False
|
||||
candidates = [path for path in paths if path.count("/") == 1 and posixpath.basename(path) == _SKILL_MD_NAME]
|
||||
return len(candidates) == 1 and candidates[0] == entry_path
|
||||
|
||||
@staticmethod
|
||||
def _read_member_bytes_from_archive(archive: zipfile.ZipFile, *, member_info: zipfile.ZipInfo) -> bytes:
|
||||
try:
|
||||
return archive.read(member_info)
|
||||
except (zipfile.BadZipFile, EOFError, OSError, RuntimeError, ValueError, zlib.error) as exc:
|
||||
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
|
||||
|
||||
@staticmethod
|
||||
def _validate_skill_md_size(member_info: zipfile.ZipInfo) -> None:
|
||||
if member_info.file_size > _MAX_SKILL_MD_BYTES:
|
||||
raise SkillPackageError("skill_md_too_large", "SKILL.md exceeds size limit", status_code=400)
|
||||
raw = archive.read(member)
|
||||
|
||||
@staticmethod
|
||||
def _decode_skill_md(raw: bytes) -> str:
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
@@ -193,4 +302,4 @@ class SkillPackageService:
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
|
||||
|
||||
__all__ = ["SkillManifest", "SkillPackageError", "SkillPackageService"]
|
||||
__all__ = ["NormalizedSkillPackage", "SkillManifest", "SkillPackageError", "SkillPackageService"]
|
||||
|
||||
@@ -7,19 +7,14 @@ to the agent drive (Agent Files §5.4 / §4):
|
||||
* ``<slug>/.DIFY-SKILL-FULL.zip`` — the full archive, kept only to restore the
|
||||
complete skill contents.
|
||||
|
||||
Both are stored as ``ToolFile`` records and bound via ``AgentDriveService.commit``
|
||||
with ``value_owned_by_drive=True`` (the drive owns their lifecycle). The returned
|
||||
payload is the slim drive-derived skill DTO the UI needs to work with the drive
|
||||
catalog — ``name``, ``description``, ``path``, ``skill_md_key``, and
|
||||
``archive_key`` — plus the extracted manifest for upload feedback. The console
|
||||
``/skills/upload`` endpoints delegate to this service so "upload" now always means
|
||||
drive-backed skill normalization rather than Agent Soul binding.
|
||||
The archive's member list is stored in skill metadata and resolved lazily for
|
||||
inspect/preview/runtime. Upload must not eagerly materialize every archive member
|
||||
as a separate ToolFile; small archives with many files would otherwise perform
|
||||
hundreds of storage writes and DB commits inside the request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import posixpath
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
@@ -38,7 +33,11 @@ def slugify_skill_name(name: str) -> str:
|
||||
|
||||
|
||||
class SkillStandardizeService:
|
||||
"""Validate + standardize a Skill package into a per-agent drive upload result."""
|
||||
"""Persist a normalized skill package into drive-owned files for one agent.
|
||||
|
||||
Instances are intentionally stateful: ``standardize()`` updates
|
||||
``last_committed_items`` with the drive commit result for the most recent call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -50,6 +49,7 @@ class SkillStandardizeService:
|
||||
self._package = package_service or SkillPackageService()
|
||||
self._drive = drive_service or AgentDriveService()
|
||||
self._tool_files = tool_file_manager or ToolFileManager()
|
||||
self.last_committed_items: list[dict[str, Any]] = []
|
||||
|
||||
def standardize(
|
||||
self,
|
||||
@@ -60,17 +60,23 @@ class SkillStandardizeService:
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
manifest = self._package.validate_and_extract(content=content, filename=filename)
|
||||
skill_md_bytes = self._package.read_member_bytes(content=content, member_path=manifest.entry_path)
|
||||
"""Create two ToolFiles, commit two drive-owned keys, and return skill metadata.
|
||||
|
||||
This writes ``<slug>/SKILL.md`` and ``<slug>/.DIFY-SKILL-FULL.zip``,
|
||||
stores the drive commit rows in ``last_committed_items``, and returns the
|
||||
console response shape ``{"skill": ..., "manifest": ...}``.
|
||||
"""
|
||||
package = self._package.validate_and_normalize(content=content, filename=filename)
|
||||
manifest = package.manifest
|
||||
slug = slugify_skill_name(manifest.name)
|
||||
|
||||
# Drive-owned files: canonical SKILL.md, every inspectable archive file,
|
||||
# and the full archive for future restore/export.
|
||||
# Drive-owned files: canonical SKILL.md and the full archive. The
|
||||
# archive member tree is preserved in metadata and resolved lazily.
|
||||
md_tool_file = self._tool_files.create_file_by_raw(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_binary=skill_md_bytes,
|
||||
file_binary=package.skill_md_bytes,
|
||||
mimetype="text/markdown",
|
||||
filename=_SKILL_MD_NAME,
|
||||
)
|
||||
@@ -78,38 +84,14 @@ class SkillStandardizeService:
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_binary=content,
|
||||
file_binary=package.archive_bytes,
|
||||
mimetype="application/zip",
|
||||
filename=_FULL_ARCHIVE_NAME,
|
||||
)
|
||||
|
||||
skill_md_key = f"{slug}/{_SKILL_MD_NAME}"
|
||||
archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}"
|
||||
member_items: list[DriveCommitItem] = []
|
||||
for member_path in sorted(set(manifest.files)):
|
||||
member_key = f"{slug}/{member_path}"
|
||||
if member_key in {skill_md_key, archive_key}:
|
||||
continue
|
||||
|
||||
member_bytes = self._package.read_member_bytes(content=content, member_path=member_path)
|
||||
mimetype = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
|
||||
member_tool_file = self._tool_files.create_file_by_raw(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_binary=member_bytes,
|
||||
mimetype=mimetype,
|
||||
filename=posixpath.basename(member_path),
|
||||
)
|
||||
member_items.append(
|
||||
DriveCommitItem(
|
||||
key=member_key,
|
||||
file_ref=DriveFileRef(kind="tool_file", id=member_tool_file.id),
|
||||
value_owned_by_drive=True,
|
||||
)
|
||||
)
|
||||
|
||||
self._drive.commit(
|
||||
committed_items = self._drive.commit(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
@@ -130,23 +112,17 @@ class SkillStandardizeService:
|
||||
file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id),
|
||||
value_owned_by_drive=True,
|
||||
),
|
||||
*member_items,
|
||||
],
|
||||
)
|
||||
|
||||
drive_skill = next(
|
||||
skill
|
||||
for skill in self._drive.list_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if skill["skill_md_key"] == skill_md_key
|
||||
)
|
||||
self.last_committed_items = committed_items
|
||||
|
||||
return {
|
||||
"skill": {
|
||||
"name": drive_skill["name"],
|
||||
"description": drive_skill["description"],
|
||||
"path": drive_skill["path"],
|
||||
"skill_md_key": drive_skill["skill_md_key"],
|
||||
"archive_key": drive_skill["archive_key"],
|
||||
"name": manifest.name,
|
||||
"description": manifest.description,
|
||||
"path": slug,
|
||||
"skill_md_key": skill_md_key,
|
||||
"archive_key": archive_key,
|
||||
},
|
||||
"manifest": manifest.model_dump(),
|
||||
}
|
||||
|
||||
@@ -18,9 +18,18 @@ from models.agent import (
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig
|
||||
from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
WorkflowNodeJobConfig,
|
||||
WorkflowPreviousNodeOutputRef,
|
||||
)
|
||||
from models.workflow import Workflow
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.prompt_mentions import (
|
||||
extract_workflow_node_output_selectors,
|
||||
workflow_previous_node_output_refs_from_selectors,
|
||||
)
|
||||
from services.entities.agent_entities import (
|
||||
ComposerSavePayload,
|
||||
ComposerSaveStrategy,
|
||||
@@ -39,10 +48,10 @@ class WorkflowAgentPublishService:
|
||||
|
||||
@classmethod
|
||||
def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]:
|
||||
"""Return draft graph with persisted Agent node job config projected into node data.
|
||||
"""Return draft graph with persisted Agent binding fields projected into node data.
|
||||
|
||||
Workflow draft graph is the front-end's editing source of truth, while
|
||||
runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This
|
||||
runtime/publish reads WorkflowAgentNodeBinding. This
|
||||
response-only projection keeps reads aligned without writing binding
|
||||
details back into the stored graph JSON.
|
||||
"""
|
||||
@@ -64,6 +73,18 @@ class WorkflowAgentPublishService:
|
||||
node_data = agent_nodes.get(binding.node_id)
|
||||
if not isinstance(node_data, dict):
|
||||
continue
|
||||
graph_binding = node_data.get(cls._AGENT_BINDING_KEY)
|
||||
is_pending_inline_graph_binding = (
|
||||
isinstance(graph_binding, Mapping)
|
||||
and graph_binding.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value
|
||||
and (not graph_binding.get("agent_id") or not graph_binding.get("current_snapshot_id"))
|
||||
)
|
||||
if not is_pending_inline_graph_binding or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
|
||||
node_data[cls._AGENT_BINDING_KEY] = {
|
||||
"binding_type": binding.binding_type.value,
|
||||
"agent_id": binding.agent_id,
|
||||
"current_snapshot_id": binding.current_snapshot_id,
|
||||
}
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
if node_job.workflow_prompt is not None:
|
||||
node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt
|
||||
@@ -231,6 +252,10 @@ class WorkflowAgentPublishService:
|
||||
continue
|
||||
if not isinstance(binding_payload, Mapping):
|
||||
raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.")
|
||||
if binding_payload.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value and (
|
||||
not binding_payload.get("agent_id") or not binding_payload.get("current_snapshot_id")
|
||||
):
|
||||
continue
|
||||
cls._sync_agent_binding_for_node(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
@@ -409,6 +434,7 @@ class WorkflowAgentPublishService:
|
||||
agent_task = node_data.get(cls._AGENT_TASK_KEY)
|
||||
if isinstance(agent_task, str):
|
||||
node_job.workflow_prompt = agent_task
|
||||
node_job.previous_node_output_refs = cls._previous_node_output_refs_from_prompt(agent_task)
|
||||
|
||||
declared_outputs_payload = node_data.get(cls._AGENT_DECLARED_OUTPUTS_KEY)
|
||||
if declared_outputs_payload is not None:
|
||||
@@ -423,6 +449,11 @@ class WorkflowAgentPublishService:
|
||||
|
||||
return node_job
|
||||
|
||||
@classmethod
|
||||
def _previous_node_output_refs_from_prompt(cls, prompt: str) -> list[WorkflowPreviousNodeOutputRef]:
|
||||
"""Derive persisted refs from the current frontend workflow markers only."""
|
||||
return workflow_previous_node_output_refs_from_selectors(extract_workflow_node_output_selectors(prompt))
|
||||
|
||||
@classmethod
|
||||
def copy_agent_node_bindings_to_published(
|
||||
cls,
|
||||
|
||||
@@ -19,10 +19,18 @@ ToolFile records (see ``AgentDriveFile``). This service is the control plane:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from typing import Any, Literal, TypedDict
|
||||
from urllib.parse import unquote
|
||||
|
||||
@@ -31,6 +39,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import DataError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.file_access.controller import DatabaseFileAccessController
|
||||
from core.db.session_factory import session_factory
|
||||
from extensions.ext_storage import storage
|
||||
@@ -46,6 +55,7 @@ _MAX_KEY_LENGTH = 512
|
||||
_DRIVE_REF_PREFIX = "agent-"
|
||||
_SKILL_MD_SUFFIX = "/SKILL.md"
|
||||
_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
|
||||
_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE = "agent-drive-archive-member"
|
||||
|
||||
|
||||
class AgentDriveError(Exception):
|
||||
@@ -365,6 +375,7 @@ class AgentDriveService:
|
||||
skill_md_key=skill_md_key,
|
||||
manifest_files=manifest_files,
|
||||
drive_keys=drive_keys,
|
||||
archive_available=catalog["archive_key"] in drive_keys if catalog["archive_key"] else False,
|
||||
)
|
||||
return {
|
||||
**catalog,
|
||||
@@ -598,6 +609,7 @@ class AgentDriveService:
|
||||
skill_md_key: str,
|
||||
manifest_files: list[str] | None,
|
||||
drive_keys: set[str],
|
||||
archive_available: bool = False,
|
||||
) -> tuple[list[AgentDriveSkillFileInfo], list[str]]:
|
||||
warnings: list[str] = []
|
||||
if manifest_files:
|
||||
@@ -617,13 +629,14 @@ class AgentDriveService:
|
||||
if path == _SKILL_ARCHIVE_NAME:
|
||||
continue
|
||||
drive_key = f"{skill_path}/{path}"
|
||||
available_in_drive = drive_key in drive_keys or (archive_available and path != _SKILL_ARCHIVE_NAME)
|
||||
files.append(
|
||||
{
|
||||
"path": path,
|
||||
"name": path.rsplit("/", 1)[-1],
|
||||
"type": "file",
|
||||
"drive_key": drive_key if drive_key in drive_keys else None,
|
||||
"available_in_drive": drive_key in drive_keys,
|
||||
"drive_key": drive_key if available_in_drive else None,
|
||||
"available_in_drive": available_in_drive,
|
||||
}
|
||||
)
|
||||
if "SKILL.md" not in {file["path"] for file in files}:
|
||||
@@ -844,56 +857,209 @@ class AgentDriveService:
|
||||
return row
|
||||
|
||||
def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str:
|
||||
if row.file_kind == AgentDriveFileKind.TOOL_FILE:
|
||||
tool_file = session.scalar(
|
||||
select(ToolFile).where(ToolFile.id == row.file_id, ToolFile.tenant_id == tenant_id)
|
||||
)
|
||||
return self._storage_key_for_ref(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
file_kind=row.file_kind,
|
||||
file_id=row.file_id,
|
||||
)
|
||||
|
||||
def _storage_key_for_ref(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
file_kind: AgentDriveFileKind,
|
||||
file_id: str,
|
||||
) -> str:
|
||||
if file_kind == AgentDriveFileKind.TOOL_FILE:
|
||||
tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id))
|
||||
if tool_file is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
|
||||
return tool_file.file_key
|
||||
upload_file = session.scalar(
|
||||
select(UploadFile).where(UploadFile.id == row.file_id, UploadFile.tenant_id == tenant_id)
|
||||
select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id)
|
||||
)
|
||||
if upload_file is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
|
||||
return upload_file.key
|
||||
|
||||
def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
|
||||
"""Truncated text preview of one drive value (binary-safe, never 500s on size)."""
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
|
||||
size = row.size
|
||||
def _archive_member_for_key(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
) -> tuple[AgentDriveFile, str]:
|
||||
normalized_key = normalize_drive_key(key)
|
||||
if "/" not in normalized_key:
|
||||
raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
|
||||
skill_path, member_path = normalized_key.split("/", 1)
|
||||
if member_path in {_SKILL_ARCHIVE_NAME, ""}:
|
||||
raise AgentDriveError("drive_key_not_found", "no archive member for this key", status_code=404)
|
||||
|
||||
data = bytearray()
|
||||
for chunk in storage.load_stream(storage_key):
|
||||
data.extend(chunk)
|
||||
if len(data) > self.PREVIEW_MAX_BYTES:
|
||||
break
|
||||
truncated = len(data) > self.PREVIEW_MAX_BYTES
|
||||
sample = bytes(data[: self.PREVIEW_MAX_BYTES])
|
||||
# Same semantics as the sandbox read endpoint: NUL or undecodable -> binary.
|
||||
skill_md_key = f"{skill_path}{_SKILL_MD_SUFFIX}"
|
||||
skill_row = session.scalar(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
AgentDriveFile.key == skill_md_key,
|
||||
AgentDriveFile.is_skill.is_(True),
|
||||
)
|
||||
)
|
||||
if skill_row is None:
|
||||
raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
|
||||
metadata = self._parse_skill_metadata(skill_row.key, skill_row.skill_metadata)
|
||||
manifest_files = {normalize_drive_key(path) for path in (metadata.manifest_files or [])}
|
||||
if member_path not in manifest_files:
|
||||
raise AgentDriveError("drive_key_not_found", "archive member is not part of this skill", status_code=404)
|
||||
archive_row = session.scalar(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
AgentDriveFile.key == self._skill_archive_key(skill_md_key),
|
||||
)
|
||||
)
|
||||
if archive_row is None:
|
||||
raise AgentDriveError("drive_key_not_found", "skill archive is missing", status_code=404)
|
||||
return archive_row, member_path
|
||||
|
||||
def _load_archive_member_bytes(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
) -> bytes:
|
||||
member_path = normalize_drive_key(member_path)
|
||||
with session_factory.create_session() as session:
|
||||
storage_key = self._storage_key_for_ref(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
file_kind=archive_file_kind,
|
||||
file_id=archive_file_id,
|
||||
)
|
||||
archive_bytes = b"".join(storage.load_stream(storage_key))
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
|
||||
member = next(
|
||||
(
|
||||
info
|
||||
for info in archive.infolist()
|
||||
if not info.is_dir() and normalize_drive_key(info.filename) == member_path
|
||||
),
|
||||
None,
|
||||
)
|
||||
if member is None:
|
||||
raise AgentDriveError(
|
||||
"drive_key_not_found", "archive member is missing from the skill archive", status_code=404
|
||||
)
|
||||
return archive.read(member)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise AgentDriveError("invalid_skill_archive", "skill archive is not a valid zip", status_code=500) from exc
|
||||
|
||||
@classmethod
|
||||
def _preview_bytes(cls, *, key: str, size: int | None, payload: bytes) -> dict[str, Any]:
|
||||
truncated = len(payload) > cls.PREVIEW_MAX_BYTES
|
||||
sample = payload[: cls.PREVIEW_MAX_BYTES]
|
||||
if b"\x00" in sample:
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
try:
|
||||
text = sample.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
if truncated:
|
||||
# A multi-byte char may sit on the cut point; retry without the tail.
|
||||
try:
|
||||
text = sample[:-3].decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError:
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
else:
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text}
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": False, "text": text}
|
||||
|
||||
def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
|
||||
"""Truncated text preview of one drive value (binary-safe, never 500s on size)."""
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
|
||||
size = row.size
|
||||
response_key = row.key
|
||||
archive_ref: tuple[AgentDriveFile, str] | None = None
|
||||
except AgentDriveError:
|
||||
archive_ref = self._archive_member_for_key(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
)
|
||||
storage_key = None
|
||||
size = None
|
||||
response_key = normalize_drive_key(key)
|
||||
|
||||
if archive_ref is not None:
|
||||
archive_row, member_path = archive_ref
|
||||
payload = self._load_archive_member_bytes(
|
||||
tenant_id=tenant_id,
|
||||
archive_file_kind=archive_row.file_kind,
|
||||
archive_file_id=archive_row.file_id,
|
||||
member_path=member_path,
|
||||
)
|
||||
return self._preview_bytes(key=response_key, size=len(payload), payload=payload)
|
||||
|
||||
data = bytearray()
|
||||
assert storage_key is not None
|
||||
for chunk in storage.load_stream(storage_key):
|
||||
data.extend(chunk)
|
||||
if len(data) > self.PREVIEW_MAX_BYTES:
|
||||
break
|
||||
return self._preview_bytes(key=response_key, size=size, payload=bytes(data))
|
||||
|
||||
def preview_archive_member_for_ref(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
) -> dict[str, Any]:
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = self._load_archive_member_bytes(
|
||||
tenant_id=tenant_id,
|
||||
archive_file_kind=archive_file_kind,
|
||||
archive_file_id=archive_file_id,
|
||||
member_path=member_path,
|
||||
)
|
||||
return self._preview_bytes(key=normalize_drive_key(key), size=len(payload), payload=payload)
|
||||
|
||||
def download_url(self, *, tenant_id: str, agent_id: str, key: str) -> str:
|
||||
"""External signed URL for a browser download of one drive value."""
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
try:
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
except AgentDriveError:
|
||||
archive_row, member_path = self._archive_member_for_key(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
)
|
||||
return self.sign_archive_member_url(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
archive_file_kind=archive_row.file_kind,
|
||||
archive_file_id=archive_row.file_id,
|
||||
member_path=member_path,
|
||||
for_external=True,
|
||||
as_attachment=True,
|
||||
)
|
||||
url = self._resolve_download_url(
|
||||
tenant_id=tenant_id,
|
||||
file_kind=row.file_kind,
|
||||
@@ -905,6 +1071,159 @@ class AgentDriveService:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
|
||||
return url
|
||||
|
||||
def download_url_archive_member_for_ref(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
for_external: bool = True,
|
||||
) -> str:
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return self.sign_archive_member_url(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
archive_file_kind=archive_file_kind,
|
||||
archive_file_id=archive_file_id,
|
||||
member_path=member_path,
|
||||
for_external=for_external,
|
||||
as_attachment=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _secret_key() -> bytes:
|
||||
return dify_config.SECRET_KEY.encode()
|
||||
|
||||
@classmethod
|
||||
def _archive_member_signature_payload(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
) -> str:
|
||||
return "|".join(
|
||||
[
|
||||
_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE,
|
||||
tenant_id,
|
||||
agent_id,
|
||||
normalize_drive_key(key),
|
||||
archive_file_kind.value,
|
||||
archive_file_id,
|
||||
normalize_drive_key(member_path),
|
||||
timestamp,
|
||||
nonce,
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _sign_archive_member_payload(cls, payload: str) -> str:
|
||||
digest = hmac.new(cls._secret_key(), payload.encode(), hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode()
|
||||
|
||||
@classmethod
|
||||
def sign_archive_member_url(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
for_external: bool,
|
||||
as_attachment: bool = False,
|
||||
) -> str:
|
||||
base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = os.urandom(16).hex()
|
||||
payload = cls._archive_member_signature_payload(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
archive_file_kind=archive_file_kind,
|
||||
archive_file_id=archive_file_id,
|
||||
member_path=member_path,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
)
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"agent_id": agent_id,
|
||||
"key": normalize_drive_key(key),
|
||||
"archive_file_kind": archive_file_kind.value,
|
||||
"archive_file_id": archive_file_id,
|
||||
"member_path": normalize_drive_key(member_path),
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
"sign": cls._sign_archive_member_payload(payload),
|
||||
"as_attachment": str(as_attachment).lower(),
|
||||
}
|
||||
)
|
||||
return f"{base_url}/files/agent-drive/archive-member?{query}"
|
||||
|
||||
@classmethod
|
||||
def verify_archive_member_signature(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
sign: str,
|
||||
) -> bool:
|
||||
payload = cls._archive_member_signature_payload(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
archive_file_kind=archive_file_kind,
|
||||
archive_file_id=archive_file_id,
|
||||
member_path=member_path,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
)
|
||||
if sign != cls._sign_archive_member_payload(payload):
|
||||
return False
|
||||
current_time = int(time.time())
|
||||
return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
|
||||
|
||||
def load_archive_member_for_signed_request(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
) -> tuple[bytes, str, str]:
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = self._load_archive_member_bytes(
|
||||
tenant_id=tenant_id,
|
||||
archive_file_kind=archive_file_kind,
|
||||
archive_file_id=archive_file_id,
|
||||
member_path=member_path,
|
||||
)
|
||||
mime_type = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
|
||||
filename = normalize_drive_key(key).rsplit("/", 1)[-1]
|
||||
return payload, mime_type, filename
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDriveError",
|
||||
|
||||
@@ -96,6 +96,17 @@ class AppService:
|
||||
filters.append(App.mode == AppMode.AGENT_CHAT)
|
||||
elif params.mode == "agent":
|
||||
filters.append(App.mode == AppMode.AGENT)
|
||||
filters.append(
|
||||
sa.exists()
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == App.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.correlate(App)
|
||||
)
|
||||
elif params.mode == "all":
|
||||
filters.append(App.mode != AppMode.AGENT)
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ from typing import Any, Literal, NotRequired, TypedDict
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, scoped_session
|
||||
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
from core.helper.http_client_pooling import get_pooled_http_client
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.helper import RateLimiter
|
||||
from models import Account, TenantAccountJoin, TenantAccountRole
|
||||
@@ -363,10 +363,10 @@ class BillingService:
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def is_tenant_owner_or_admin(current_user: Account):
|
||||
def is_tenant_owner_or_admin(session: Session | scoped_session, current_user: Account):
|
||||
tenant_id = current_user.current_tenant_id
|
||||
|
||||
join: TenantAccountJoin | None = db.session.scalar(
|
||||
join: TenantAccountJoin | None = session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == current_user.id)
|
||||
.limit(1)
|
||||
|
||||
@@ -125,9 +125,9 @@ class ClearFreePlanTenantExpiredLogs:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def process_tenant(cls, flask_app: Flask, tenant_id: str, days: int, batch: int):
|
||||
def process_tenant(cls, flask_app: Flask, tenant_id: str, days: int, batch: int, session: Session):
|
||||
with flask_app.app_context():
|
||||
apps = db.session.scalars(select(App).where(App.tenant_id == tenant_id)).all()
|
||||
apps = session.scalars(select(App).where(App.tenant_id == tenant_id)).all()
|
||||
app_ids = [app.id for app in apps]
|
||||
while True:
|
||||
with sessionmaker(bind=db.engine, autoflush=False).begin() as session:
|
||||
@@ -375,7 +375,8 @@ class ClearFreePlanTenantExpiredLogs:
|
||||
or BillingService.get_info(tenant_id)["subscription"]["plan"] == CloudPlan.SANDBOX
|
||||
):
|
||||
# only process sandbox tenant
|
||||
cls.process_tenant(flask_app, tenant_id, days, batch)
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
cls.process_tenant(flask_app, tenant_id, days, batch, session)
|
||||
except Exception:
|
||||
logger.exception("Failed to process tenant %s", tenant_id)
|
||||
finally:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
from sqlalchemy.orm import InstrumentedAttribute, Session, scoped_session
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account
|
||||
from models.credential_permission import CredentialPermission
|
||||
from models.enums import PermissionEnum
|
||||
@@ -17,9 +16,11 @@ class CredentialPermissionService:
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_partial_member_list(cls, credential_id: str, credential_type: str) -> Sequence[str]:
|
||||
def get_partial_member_list(
|
||||
cls, session: Session | scoped_session, credential_id: str, credential_type: str
|
||||
) -> Sequence[str]:
|
||||
"""Return account_ids that have partial-member access to a credential."""
|
||||
return db.session.scalars(
|
||||
return session.scalars(
|
||||
select(CredentialPermission.account_id).where(
|
||||
CredentialPermission.credential_id == credential_id,
|
||||
CredentialPermission.credential_type == credential_type,
|
||||
|
||||
@@ -248,7 +248,7 @@ class DatasetService:
|
||||
def get_datasets(
|
||||
page,
|
||||
per_page,
|
||||
session: scoped_session | Session | None = None,
|
||||
session: scoped_session | Session,
|
||||
tenant_id=None,
|
||||
user=None,
|
||||
search=None,
|
||||
@@ -257,7 +257,7 @@ class DatasetService:
|
||||
accessible_dataset_ids: list[str] | None = None,
|
||||
include_own_datasets: bool = False,
|
||||
):
|
||||
session = session or db.session
|
||||
"""Return visible datasets for a tenant, using the injected session for auxiliary permission lookups."""
|
||||
query = select(Dataset).where(Dataset.tenant_id == tenant_id).order_by(Dataset.created_at.desc(), Dataset.id)
|
||||
|
||||
if dify_config.RBAC_ENABLED and accessible_dataset_ids is not None:
|
||||
@@ -268,7 +268,7 @@ class DatasetService:
|
||||
|
||||
if user:
|
||||
# get permitted dataset ids
|
||||
dataset_permission = db.session.scalars(
|
||||
dataset_permission = session.scalars(
|
||||
select(DatasetPermission).where(
|
||||
DatasetPermission.account_id == user.id, DatasetPermission.tenant_id == tenant_id
|
||||
)
|
||||
@@ -652,7 +652,7 @@ class DatasetService:
|
||||
raise ValueError("Dataset name already exists")
|
||||
|
||||
# Verify user has permission to update this dataset
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
DatasetService.check_dataset_permission(dataset, user, db.session)
|
||||
|
||||
# Handle external dataset updates
|
||||
if dataset.provider == "external":
|
||||
@@ -1311,7 +1311,7 @@ class DatasetService:
|
||||
if dataset is None:
|
||||
return False
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
DatasetService.check_dataset_permission(dataset, user, db.session)
|
||||
|
||||
dataset_was_deleted.send(dataset)
|
||||
|
||||
@@ -1325,7 +1325,8 @@ class DatasetService:
|
||||
return db.session.execute(stmt).scalar_one()
|
||||
|
||||
@staticmethod
|
||||
def check_dataset_permission(dataset, user):
|
||||
def check_dataset_permission(dataset, user, session: scoped_session | Session):
|
||||
"""Validate dataset access for a user, using the injected session for partial-member lookups."""
|
||||
if dataset.tenant_id != user.current_tenant_id:
|
||||
logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id)
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
@@ -1336,7 +1337,7 @@ class DatasetService:
|
||||
if dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
|
||||
# For partial team permission, user needs explicit permission or be the maintainer.
|
||||
if dataset.maintainer != user.id:
|
||||
user_permission = db.session.scalar(
|
||||
user_permission = session.scalar(
|
||||
select(DatasetPermission)
|
||||
.where(DatasetPermission.dataset_id == dataset.id, DatasetPermission.account_id == user.id)
|
||||
.limit(1)
|
||||
@@ -1728,7 +1729,7 @@ class DocumentService:
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ class ComposerSoulLockPayload(BaseModel):
|
||||
unlocked_from_version_id: str | None = None
|
||||
|
||||
|
||||
class WorkflowAgentComposerQuery(BaseModel):
|
||||
snapshot_id: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class ComposerSavePayload(BaseModel):
|
||||
variant: ComposerVariant
|
||||
binding: ComposerBindingPayload | None = None
|
||||
|
||||
@@ -173,14 +173,6 @@ class InnerKnowledgeRetrieveRequest(BaseModel):
|
||||
class InnerKnowledgeRetrieveUsage(ResponseModel):
|
||||
"""Serialized LLM usage payload returned by dataset retrieval."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="forbid",
|
||||
populate_by_name=True,
|
||||
serialize_by_alias=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
|
||||
@@ -7,10 +7,13 @@ from sqlalchemy import or_, select
|
||||
from constants import HIDDEN_VALUE
|
||||
from core.entities.provider_configuration import ProviderConfiguration
|
||||
from core.helper import encrypter
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
|
||||
from core.helper.model_provider_cache import (
|
||||
ProviderCredentialsCache,
|
||||
ProviderCredentialsCacheType,
|
||||
)
|
||||
from core.model_manager import LBModelManager
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly, create_plugin_provider_manager
|
||||
from core.provider_manager import ProviderManager
|
||||
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import (
|
||||
@@ -313,6 +316,10 @@ class ModelLoadBalancingService:
|
||||
)
|
||||
db.session.add(inherit_config)
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
return inherit_config
|
||||
|
||||
@@ -434,6 +441,10 @@ class ModelLoadBalancingService:
|
||||
load_balancing_config.enabled = enabled
|
||||
load_balancing_config.updated_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
self._clear_credentials_cache(tenant_id, config_id)
|
||||
else:
|
||||
@@ -487,12 +498,20 @@ class ModelLoadBalancingService:
|
||||
|
||||
db.session.add(load_balancing_model_config)
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
# get deleted config ids
|
||||
deleted_config_ids = set(current_load_balancing_configs_dict.keys()) - updated_config_ids
|
||||
for config_id in deleted_config_ids:
|
||||
db.session.delete(current_load_balancing_configs_dict[config_id])
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
self._clear_credentials_cache(tenant_id, config_id)
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ Archive V2 writes bundle-level Parquet objects. A bundle contains many workflow
|
||||
Bundle metadata lives in the object-store manifest instead of a database table, so archive/delete/restore does not move
|
||||
the large-table retention problem into another OLTP table.
|
||||
|
||||
Archive campaigns should use fixed absolute UTC windows for every tenant-prefix/shard execution. Relative windows are
|
||||
evaluated at process start and are not safe for multi-day rollout because each command would scan a different window.
|
||||
Per-shard `index.json` objects are derived from bundle manifests and provide run-level idempotency without adding a
|
||||
database claim table; bundle manifests remain the source of truth when an index must be rebuilt.
|
||||
|
||||
Archived tables:
|
||||
- workflow_runs
|
||||
- workflow_app_logs
|
||||
@@ -25,9 +30,11 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, TypedDict
|
||||
from threading import Lock
|
||||
from typing import Any, NotRequired, TypedDict, cast
|
||||
|
||||
import click
|
||||
import pyarrow as pa
|
||||
@@ -59,6 +66,7 @@ from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWo
|
||||
from services.billing_service import BillingService
|
||||
from services.retention.workflow_run.constants import (
|
||||
ARCHIVE_BUNDLE_FORMAT,
|
||||
ARCHIVE_BUNDLE_INDEX_NAME,
|
||||
ARCHIVE_BUNDLE_MANIFEST_NAME,
|
||||
ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
)
|
||||
@@ -90,10 +98,24 @@ class ArchiveManifestDict(TypedDict):
|
||||
min_run_id: str
|
||||
max_run_id: str
|
||||
archived_at: str
|
||||
campaign_id: str
|
||||
archive_window_start: str | None
|
||||
archive_window_end: str
|
||||
run_shard: str
|
||||
tables: dict[str, TableStatsManifestEntry]
|
||||
run_ids: list[str]
|
||||
|
||||
|
||||
class ArchiveBundleIndexDict(TypedDict):
|
||||
schema_version: str
|
||||
archive_format: str
|
||||
object_prefix: str
|
||||
updated_at: str
|
||||
manifest_keys: list[str]
|
||||
run_ids: list[str]
|
||||
campaign_ids: NotRequired[list[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchiveBundleIdentity:
|
||||
"""Stable identity and object prefix for one V2 archive bundle."""
|
||||
@@ -127,6 +149,7 @@ class ArchiveResult:
|
||||
object_prefix: str
|
||||
success: bool
|
||||
run_count: int = 0
|
||||
skipped_run_count: int = 0
|
||||
tables: list[TableStats] = field(default_factory=list)
|
||||
object_size_bytes: int = 0
|
||||
skipped: bool = False
|
||||
@@ -192,11 +215,14 @@ class WorkflowRunArchiver:
|
||||
tenant_prefixes: list[str]
|
||||
run_shard_index: int | None
|
||||
run_shard_total: int | None
|
||||
campaign_id: str
|
||||
_archive_index_cache: dict[str, set[str]]
|
||||
_archive_index_cache_lock: Lock
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
days: int = 90,
|
||||
batch_size: int = 100,
|
||||
batch_size: int = 10000,
|
||||
start_from: datetime.datetime | None = None,
|
||||
end_before: datetime.datetime | None = None,
|
||||
workers: int = 1,
|
||||
@@ -238,10 +264,10 @@ class WorkflowRunArchiver:
|
||||
if start_from or end_before:
|
||||
if start_from is None or end_before is None:
|
||||
raise ValueError("start_from and end_before must be provided together")
|
||||
if start_from >= end_before:
|
||||
self.start_from = self._normalize_utc_datetime(start_from)
|
||||
self.end_before = self._normalize_utc_datetime(end_before)
|
||||
if self.start_from >= self.end_before:
|
||||
raise ValueError("start_from must be earlier than end_before")
|
||||
self.start_from = start_from.replace(tzinfo=datetime.UTC)
|
||||
self.end_before = end_before.replace(tzinfo=datetime.UTC)
|
||||
else:
|
||||
self.start_from = None
|
||||
self.end_before = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days)
|
||||
@@ -259,6 +285,9 @@ class WorkflowRunArchiver:
|
||||
raise ValueError("run_shard_index must be between 0 and run_shard_total - 1")
|
||||
self.run_shard_index = run_shard_index
|
||||
self.run_shard_total = run_shard_total
|
||||
self.campaign_id = self._build_campaign_id()
|
||||
self._archive_index_cache = {}
|
||||
self._archive_index_cache_lock = Lock()
|
||||
self.limit = limit
|
||||
self.dry_run = dry_run
|
||||
self.delete_after_archive = delete_after_archive
|
||||
@@ -322,24 +351,23 @@ class WorkflowRunArchiver:
|
||||
if not runs_to_process:
|
||||
continue
|
||||
|
||||
for bundle_runs in self._group_runs_for_bundles(runs_to_process):
|
||||
summary.total_bundles_processed += 1
|
||||
with session_maker() as session:
|
||||
result = self._archive_bundle(session, storage, bundle_runs)
|
||||
|
||||
bundle_groups = self._group_runs_for_bundles(runs_to_process)
|
||||
summary.total_bundles_processed += len(bundle_groups)
|
||||
for result in self._archive_bundle_groups(session_maker, storage, bundle_groups):
|
||||
attempted_count += result.run_count + result.skipped_run_count
|
||||
summary.runs_skipped += result.skipped_run_count
|
||||
if result.skipped:
|
||||
attempted_count += result.run_count
|
||||
summary.bundles_skipped += 1
|
||||
summary.runs_skipped += result.run_count
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Skipped bundle {result.bundle_id} (tenant={result.tenant_id}, "
|
||||
f"runs={result.run_count}, reason={result.error or 'already handled'})",
|
||||
f"runs={result.run_count}, skipped_runs={result.skipped_run_count}, "
|
||||
f"reason={result.error or 'already handled'})",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
elif result.success:
|
||||
attempted_count += result.run_count
|
||||
summary.bundles_archived += 1
|
||||
summary.runs_archived += result.run_count
|
||||
self._merge_result_stats(summary, result)
|
||||
@@ -347,15 +375,14 @@ class WorkflowRunArchiver:
|
||||
click.style(
|
||||
f"{'[DRY RUN] Would archive' if self.dry_run else 'Archived'} "
|
||||
f"bundle {result.bundle_id} (tenant={result.tenant_id}, runs={result.run_count}, "
|
||||
f"tables={len(result.tables)}, object_size_bytes={result.object_size_bytes}, "
|
||||
f"time={result.elapsed_time:.2f}s)",
|
||||
f"skipped_runs={result.skipped_run_count}, tables={len(result.tables)}, "
|
||||
f"object_size_bytes={result.object_size_bytes}, time={result.elapsed_time:.2f}s)",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
if self.dry_run:
|
||||
self._echo_table_estimates(result.tables)
|
||||
else:
|
||||
attempted_count += result.run_count
|
||||
summary.bundles_failed += 1
|
||||
summary.runs_failed += result.run_count
|
||||
click.echo(
|
||||
@@ -493,6 +520,35 @@ class WorkflowRunArchiver:
|
||||
|
||||
return paid
|
||||
|
||||
def _archive_bundle_groups(
|
||||
self,
|
||||
session_maker: sessionmaker[Session],
|
||||
storage: ArchiveStorage | None,
|
||||
bundle_groups: Sequence[Sequence[WorkflowRun]],
|
||||
) -> list[ArchiveResult]:
|
||||
"""Archive grouped bundles, optionally in parallel."""
|
||||
if not bundle_groups:
|
||||
return []
|
||||
if self.workers == 1 or len(bundle_groups) == 1:
|
||||
results: list[ArchiveResult] = []
|
||||
for bundle_runs in bundle_groups:
|
||||
with session_maker() as session:
|
||||
results.append(self._archive_bundle(session, storage, bundle_runs))
|
||||
return results
|
||||
|
||||
results = []
|
||||
max_workers = min(self.workers, len(bundle_groups))
|
||||
|
||||
def archive_in_worker(bundle_runs: Sequence[WorkflowRun]) -> ArchiveResult:
|
||||
with session_maker() as session:
|
||||
return self._archive_bundle(session, storage, bundle_runs)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [executor.submit(archive_in_worker, bundle_runs) for bundle_runs in bundle_groups]
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
return results
|
||||
|
||||
def _archive_bundle(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -503,6 +559,7 @@ class WorkflowRunArchiver:
|
||||
if not runs:
|
||||
raise ValueError("runs must not be empty")
|
||||
start_time = time.time()
|
||||
original_run_count = len(runs)
|
||||
identity = self._build_bundle_identity(runs)
|
||||
result = ArchiveResult(
|
||||
bundle_id=identity.bundle_id,
|
||||
@@ -517,12 +574,36 @@ class WorkflowRunArchiver:
|
||||
if storage is None:
|
||||
raise ArchiveStorageNotConfiguredError("Archive storage not configured")
|
||||
if storage.object_exists(self._get_manifest_object_key(identity)):
|
||||
self._write_bundle_index(storage, identity)
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "bundle already archived"
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
archived_run_ids = self._load_archived_run_ids_for_identity(storage, identity)
|
||||
runs = [run for run in runs if run.id not in archived_run_ids]
|
||||
result.skipped_run_count = original_run_count - len(runs)
|
||||
if not runs:
|
||||
result.run_count = 0
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "all runs already archived in shard index"
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
identity = self._build_bundle_identity(runs)
|
||||
result.bundle_id = identity.bundle_id
|
||||
result.object_prefix = identity.object_prefix
|
||||
result.run_count = len(runs)
|
||||
if storage.object_exists(self._get_manifest_object_key(identity)):
|
||||
self._write_bundle_index(storage, identity)
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "filtered bundle already archived"
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
locked_runs = self._lock_runs_for_archive(session, [run.id for run in runs])
|
||||
if len(locked_runs) != len(runs):
|
||||
result.success = True
|
||||
@@ -547,6 +628,7 @@ class WorkflowRunArchiver:
|
||||
for table_name, payload in table_payloads.items():
|
||||
storage.put_object(self._get_table_object_key(identity, table_name), payload)
|
||||
storage.put_object(self._get_manifest_object_key(identity), manifest_data)
|
||||
self._merge_bundle_manifest_into_index(storage, identity, [run.id for run in runs])
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
@@ -596,49 +678,68 @@ class WorkflowRunArchiver:
|
||||
session: Session,
|
||||
runs: Sequence[WorkflowRun],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Extract all archived table rows for a bundle."""
|
||||
"""Extract archived rows using Core mappings to avoid ORM hydration on large retention batches."""
|
||||
run_ids = [run.id for run in runs]
|
||||
table_data: dict[str, list[dict[str, Any]]] = {}
|
||||
table_data["workflow_runs"] = [self._row_to_dict(run) for run in runs]
|
||||
|
||||
app_logs = list(session.scalars(select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids))))
|
||||
table_data["workflow_app_logs"] = [self._row_to_dict(row) for row in app_logs]
|
||||
|
||||
node_exec_records = list(
|
||||
session.scalars(
|
||||
select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.workflow_run_id.in_(run_ids))
|
||||
)
|
||||
table_data["workflow_app_logs"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowAppLog,
|
||||
WorkflowAppLog.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
node_exec_ids = [record.id for record in node_exec_records]
|
||||
offload_records = []
|
||||
if node_exec_ids:
|
||||
offload_records = list(
|
||||
session.scalars(
|
||||
select(WorkflowNodeExecutionOffload).where(
|
||||
WorkflowNodeExecutionOffload.node_execution_id.in_(node_exec_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
table_data["workflow_node_executions"] = [self._row_to_dict(row) for row in node_exec_records]
|
||||
table_data["workflow_node_execution_offload"] = [self._row_to_dict(row) for row in offload_records]
|
||||
|
||||
pause_records = list(session.scalars(select(WorkflowPause).where(WorkflowPause.workflow_run_id.in_(run_ids))))
|
||||
pause_ids = [pause.id for pause in pause_records]
|
||||
pause_reason_records = []
|
||||
if pause_ids:
|
||||
pause_reason_records = list(
|
||||
session.scalars(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids)))
|
||||
)
|
||||
table_data["workflow_pauses"] = [self._row_to_dict(row) for row in pause_records]
|
||||
table_data["workflow_pause_reasons"] = [self._row_to_dict(row) for row in pause_reason_records]
|
||||
node_exec_records = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowNodeExecutionModel,
|
||||
WorkflowNodeExecutionModel.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
node_exec_ids = [str(record["id"]) for record in node_exec_records]
|
||||
table_data["workflow_node_executions"] = node_exec_records
|
||||
table_data["workflow_node_execution_offload"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowNodeExecutionOffload,
|
||||
WorkflowNodeExecutionOffload.node_execution_id,
|
||||
node_exec_ids,
|
||||
)
|
||||
|
||||
trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(session)
|
||||
trigger_records: list[WorkflowTriggerLog] = []
|
||||
for run_id in run_ids:
|
||||
trigger_records.extend(trigger_repo.list_by_run_id(run_id))
|
||||
table_data["workflow_trigger_logs"] = [self._row_to_dict(row) for row in trigger_records]
|
||||
pause_records = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowPause,
|
||||
WorkflowPause.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
pause_ids = [str(record["id"]) for record in pause_records]
|
||||
table_data["workflow_pauses"] = pause_records
|
||||
table_data["workflow_pause_reasons"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowPauseReason,
|
||||
WorkflowPauseReason.pause_id,
|
||||
pause_ids,
|
||||
)
|
||||
|
||||
table_data["workflow_trigger_logs"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowTriggerLog,
|
||||
WorkflowTriggerLog.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
return table_data
|
||||
|
||||
@staticmethod
|
||||
def _select_rows_by_column(
|
||||
session: Session,
|
||||
model: Any,
|
||||
column: Any,
|
||||
values: Sequence[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not values:
|
||||
return []
|
||||
stmt = select(*model.__table__.columns).where(column.in_(values))
|
||||
return [dict(row) for row in session.execute(stmt).mappings().all()]
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
mapper = inspect(row).mapper
|
||||
@@ -690,6 +791,9 @@ class WorkflowRunArchiver:
|
||||
for stat in table_stats
|
||||
}
|
||||
sorted_runs = sorted(runs, key=lambda run: (run.created_at, run.id))
|
||||
end_before = self.end_before
|
||||
if end_before is None:
|
||||
raise ValueError("archive window end must be set")
|
||||
return ArchiveManifestDict(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
@@ -707,6 +811,10 @@ class WorkflowRunArchiver:
|
||||
min_run_id=min(run.id for run in runs),
|
||||
max_run_id=max(run.id for run in runs),
|
||||
archived_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
campaign_id=self.campaign_id,
|
||||
archive_window_start=self._format_window_datetime(self.start_from),
|
||||
archive_window_end=end_before.isoformat(),
|
||||
run_shard=identity.shard,
|
||||
tables=tables,
|
||||
run_ids=[run.id for run in sorted_runs],
|
||||
)
|
||||
@@ -773,6 +881,158 @@ class WorkflowRunArchiver:
|
||||
return "00-of-01"
|
||||
return f"{self.run_shard_index:02d}-of-{self.run_shard_total:02d}"
|
||||
|
||||
def _load_archived_run_ids_for_identity(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> set[str]:
|
||||
index_key = self._get_index_object_key(identity)
|
||||
with self._archive_index_cache_lock:
|
||||
cached_run_ids = self._archive_index_cache.get(index_key)
|
||||
if cached_run_ids is not None:
|
||||
return set(cached_run_ids)
|
||||
|
||||
if storage.object_exists(index_key):
|
||||
index = self._load_bundle_index(storage, identity)
|
||||
else:
|
||||
index = self._write_bundle_index(storage, identity)
|
||||
run_ids = set(index["run_ids"])
|
||||
with self._archive_index_cache_lock:
|
||||
self._archive_index_cache[index_key] = set(run_ids)
|
||||
return run_ids
|
||||
|
||||
def _load_bundle_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> ArchiveBundleIndexDict:
|
||||
index_key = self._get_index_object_key(identity)
|
||||
payload = storage.get_object(index_key)
|
||||
loaded = json.loads(payload)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(f"archive index must be an object: {index_key}")
|
||||
index = cast(ArchiveBundleIndexDict, loaded)
|
||||
expected_prefix = self._get_shard_object_prefix(identity)
|
||||
if index["schema_version"] != ARCHIVE_BUNDLE_SCHEMA_VERSION:
|
||||
raise ValueError(f"unsupported archive index schema_version: {index['schema_version']}")
|
||||
if index["archive_format"] != ARCHIVE_BUNDLE_FORMAT:
|
||||
raise ValueError(f"unsupported archive index archive_format: {index['archive_format']}")
|
||||
if index["object_prefix"] != expected_prefix:
|
||||
raise ValueError("archive index object_prefix does not match shard prefix")
|
||||
return index
|
||||
|
||||
def _write_bundle_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> ArchiveBundleIndexDict:
|
||||
index = self._build_bundle_index(storage, identity)
|
||||
storage.put_object(self._get_index_object_key(identity), json.dumps(index, indent=2).encode("utf-8"))
|
||||
with self._archive_index_cache_lock:
|
||||
self._archive_index_cache[self._get_index_object_key(identity)] = set(index["run_ids"])
|
||||
return index
|
||||
|
||||
def _merge_bundle_manifest_into_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
run_ids: Sequence[str],
|
||||
) -> ArchiveBundleIndexDict:
|
||||
index_key = self._get_index_object_key(identity)
|
||||
if storage.object_exists(index_key):
|
||||
index = self._load_bundle_index(storage, identity)
|
||||
else:
|
||||
index = self._build_bundle_index(storage, identity)
|
||||
|
||||
manifest_keys = sorted(set(index["manifest_keys"]) | {self._get_manifest_object_key(identity)})
|
||||
indexed_run_ids = sorted(set(index["run_ids"]) | set(run_ids))
|
||||
campaign_id_set: set[str] = set(index.get("campaign_ids", []))
|
||||
campaign_id_set.add(self.campaign_id)
|
||||
campaign_ids = sorted(campaign_id_set)
|
||||
updated_index = ArchiveBundleIndexDict(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
object_prefix=self._get_shard_object_prefix(identity),
|
||||
updated_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
manifest_keys=manifest_keys,
|
||||
run_ids=indexed_run_ids,
|
||||
campaign_ids=campaign_ids,
|
||||
)
|
||||
storage.put_object(index_key, json.dumps(updated_index, indent=2).encode("utf-8"))
|
||||
with self._archive_index_cache_lock:
|
||||
self._archive_index_cache[index_key] = set(indexed_run_ids)
|
||||
return updated_index
|
||||
|
||||
def _build_bundle_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> ArchiveBundleIndexDict:
|
||||
shard_prefix = self._get_shard_object_prefix(identity)
|
||||
manifest_keys = sorted(
|
||||
key for key in storage.list_objects(shard_prefix) if key.endswith(f"/{ARCHIVE_BUNDLE_MANIFEST_NAME}")
|
||||
)
|
||||
run_ids: set[str] = set()
|
||||
campaign_ids: set[str] = set()
|
||||
for manifest_key in manifest_keys:
|
||||
manifest_payload = storage.get_object(manifest_key)
|
||||
manifest = json.loads(manifest_payload)
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError(f"archive manifest must be an object: {manifest_key}")
|
||||
if manifest.get("schema_version") != ARCHIVE_BUNDLE_SCHEMA_VERSION:
|
||||
raise ValueError(
|
||||
f"unsupported bundle schema_version in {manifest_key}: {manifest.get('schema_version')}"
|
||||
)
|
||||
if manifest.get("archive_format") != ARCHIVE_BUNDLE_FORMAT:
|
||||
raise ValueError(
|
||||
f"unsupported bundle archive_format in {manifest_key}: {manifest.get('archive_format')}"
|
||||
)
|
||||
manifest_run_ids = manifest.get("run_ids")
|
||||
if not isinstance(manifest_run_ids, list):
|
||||
raise ValueError(f"manifest run_ids must be a list: {manifest_key}")
|
||||
run_ids.update(str(run_id) for run_id in manifest_run_ids)
|
||||
campaign_id = manifest.get("campaign_id")
|
||||
if isinstance(campaign_id, str):
|
||||
campaign_ids.add(campaign_id)
|
||||
return ArchiveBundleIndexDict(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
object_prefix=shard_prefix,
|
||||
updated_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
manifest_keys=manifest_keys,
|
||||
run_ids=sorted(run_ids),
|
||||
campaign_ids=sorted(campaign_ids),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=datetime.UTC)
|
||||
return value.astimezone(datetime.UTC)
|
||||
|
||||
def _build_campaign_id(self) -> str:
|
||||
start = self._format_window_datetime(self.start_from) or "unbounded"
|
||||
end = self._format_window_datetime(self.end_before)
|
||||
return f"{start}_{end}"
|
||||
|
||||
@staticmethod
|
||||
def _format_window_datetime(value: datetime.datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = WorkflowRunArchiver._normalize_utc_datetime(value)
|
||||
return normalized.isoformat().replace("+00:00", "Z")
|
||||
|
||||
@staticmethod
|
||||
def _get_shard_object_prefix(identity: ArchiveBundleIdentity) -> str:
|
||||
return (
|
||||
f"workflow-runs/v2/tenant_prefix={identity.tenant_prefix}/tenant_id={identity.tenant_id}/"
|
||||
f"year={identity.year:04d}/month={identity.month:02d}/shard={identity.shard}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_index_object_key(cls, identity: ArchiveBundleIdentity) -> str:
|
||||
return f"{cls._get_shard_object_prefix(identity)}/{ARCHIVE_BUNDLE_INDEX_NAME}"
|
||||
|
||||
@staticmethod
|
||||
def _get_table_object_key(identity: ArchiveBundleIdentity, table_name: str) -> str:
|
||||
return f"{identity.object_prefix}/{table_name}.parquet"
|
||||
|
||||
@@ -4,6 +4,7 @@ ARCHIVE_BUNDLE_NAME = f"archive.v{ARCHIVE_SCHEMA_VERSION}.zip"
|
||||
ARCHIVE_BUNDLE_SCHEMA_VERSION = "2.0"
|
||||
ARCHIVE_BUNDLE_FORMAT = "parquet"
|
||||
ARCHIVE_BUNDLE_MANIFEST_NAME = "manifest.json"
|
||||
ARCHIVE_BUNDLE_INDEX_NAME = "index.json"
|
||||
ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME = "_DELETE_STARTED"
|
||||
ARCHIVE_BUNDLE_DELETED_MARKER_NAME = "_DELETED"
|
||||
ARCHIVE_BUNDLE_RESTORE_STARTED_MARKER_NAME = "_RESTORE_STARTED"
|
||||
|
||||
@@ -427,7 +427,7 @@ class BuiltinToolManageService:
|
||||
if vis_str == "partial_members":
|
||||
credential_entity.partial_member_list = list(
|
||||
CredentialPermissionService.get_partial_member_list(
|
||||
provider.id, CredPermType.BUILTIN_TOOL_PROVIDER
|
||||
db.session, provider.id, CredPermType.BUILTIN_TOOL_PROVIDER
|
||||
)
|
||||
)
|
||||
if provider.id in borrowed_ids:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
@@ -14,6 +14,24 @@ from services.retention.workflow_run.archive_paid_plan_workflow_run import (
|
||||
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_FORMAT, ARCHIVE_BUNDLE_SCHEMA_VERSION
|
||||
|
||||
|
||||
class FakeArchiveStorage:
|
||||
def __init__(self, objects: dict[str, bytes] | None = None):
|
||||
self.objects = objects or {}
|
||||
|
||||
def object_exists(self, key: str) -> bool:
|
||||
return key in self.objects
|
||||
|
||||
def get_object(self, key: str) -> bytes:
|
||||
return self.objects[key]
|
||||
|
||||
def put_object(self, key: str, data: bytes) -> str:
|
||||
self.objects[key] = data
|
||||
return "checksum"
|
||||
|
||||
def list_objects(self, prefix: str) -> list[str]:
|
||||
return sorted(key for key in self.objects if key.startswith(prefix))
|
||||
|
||||
|
||||
class TestWorkflowRunArchiverInit:
|
||||
def test_start_from_without_end_before_raises(self):
|
||||
with pytest.raises(ValueError, match="start_from and end_before must be provided together"):
|
||||
@@ -162,7 +180,9 @@ class TestBuildArchiveBundle:
|
||||
|
||||
class TestGenerateManifest:
|
||||
def test_manifest_structure(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
start = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC)
|
||||
end = datetime.datetime(2025, 4, 1, tzinfo=datetime.UTC)
|
||||
archiver = WorkflowRunArchiver(start_from=start, end_before=end, run_shard_index=1, run_shard_total=4)
|
||||
from services.retention.workflow_run.archive_paid_plan_workflow_run import TableStats
|
||||
|
||||
run = MagicMock()
|
||||
@@ -197,6 +217,10 @@ class TestGenerateManifest:
|
||||
assert manifest["workflow_run_count"] == 1
|
||||
assert manifest["workflow_node_execution_count"] == 2
|
||||
assert manifest["run_ids"] == [run.id]
|
||||
assert manifest["campaign_id"] == "2025-01-01T00:00:00Z_2025-04-01T00:00:00Z"
|
||||
assert manifest["archive_window_start"] == "2025-01-01T00:00:00Z"
|
||||
assert manifest["archive_window_end"] == "2025-04-01T00:00:00Z"
|
||||
assert manifest["run_shard"] == "01-of-04"
|
||||
assert "tables" in manifest
|
||||
assert manifest["tables"]["workflow_runs"]["row_count"] == 1
|
||||
assert manifest["tables"]["workflow_runs"]["checksum"] == "abc123"
|
||||
@@ -328,6 +352,21 @@ class TestDryRunArchive:
|
||||
|
||||
|
||||
class TestArchiveRunIdempotency:
|
||||
def _index_payload(self, archiver: WorkflowRunArchiver, run_ids: list[str], run) -> tuple[str, bytes]:
|
||||
identity = archiver._build_bundle_identity([run])
|
||||
index_key = archiver._get_index_object_key(identity)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"schema_version": ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
"archive_format": ARCHIVE_BUNDLE_FORMAT,
|
||||
"object_prefix": archiver._get_shard_object_prefix(identity),
|
||||
"updated_at": "2025-03-15T00:00:00Z",
|
||||
"manifest_keys": [],
|
||||
"run_ids": run_ids,
|
||||
}
|
||||
).encode()
|
||||
return index_key, payload
|
||||
|
||||
def test_locked_bundle_is_skipped(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = MagicMock()
|
||||
@@ -360,3 +399,47 @@ class TestArchiveRunIdempotency:
|
||||
assert result.success is True
|
||||
assert result.skipped is True
|
||||
assert result.error == "bundle already archived"
|
||||
|
||||
def test_index_skips_all_already_archived_runs(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.tenant_id = "tenant-1"
|
||||
run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
|
||||
index_key, index_payload = self._index_payload(archiver, ["run-1"], run)
|
||||
storage = FakeArchiveStorage({index_key: index_payload})
|
||||
|
||||
result = archiver._archive_bundle(MagicMock(), storage, [run])
|
||||
|
||||
assert result.success is True
|
||||
assert result.skipped is True
|
||||
assert result.run_count == 0
|
||||
assert result.skipped_run_count == 1
|
||||
assert result.error == "all runs already archived in shard index"
|
||||
|
||||
def test_index_filters_duplicate_runs_before_archive(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
archived_run = MagicMock()
|
||||
archived_run.id = "run-1"
|
||||
archived_run.tenant_id = "tenant-1"
|
||||
archived_run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
|
||||
new_run = MagicMock()
|
||||
new_run.id = "run-2"
|
||||
new_run.tenant_id = "tenant-1"
|
||||
new_run.created_at = datetime.datetime(2025, 3, 15, 11, 0, 0)
|
||||
index_key, index_payload = self._index_payload(archiver, ["run-1"], archived_run)
|
||||
storage = FakeArchiveStorage({index_key: index_payload})
|
||||
|
||||
with (
|
||||
patch.object(archiver, "_lock_runs_for_archive", return_value=[new_run]) as lock_runs,
|
||||
patch.object(archiver, "_extract_bundle_data", return_value={"workflow_runs": [{"id": "run-2"}]}),
|
||||
):
|
||||
result = archiver._archive_bundle(MagicMock(), storage, [archived_run, new_run])
|
||||
|
||||
assert result.success is True
|
||||
assert result.skipped is False
|
||||
assert result.run_count == 1
|
||||
assert result.skipped_run_count == 1
|
||||
lock_runs.assert_called_once_with(ANY, ["run-2"])
|
||||
manifest_keys = [key for key in storage.objects if key.endswith("/manifest.json")]
|
||||
assert len(manifest_keys) == 1
|
||||
|
||||
+11
-2
@@ -343,8 +343,13 @@ class TestSiteEndpoints:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_site_response_structure(self):
|
||||
payload = AppSiteUpdatePayload(title="My Site", description="Test site")
|
||||
payload = AppSiteUpdatePayload(
|
||||
title="My Site",
|
||||
description="Test site",
|
||||
input_placeholder="Ask me anything",
|
||||
)
|
||||
assert payload.title == "My Site"
|
||||
assert payload.input_placeholder == "Ask me anything"
|
||||
|
||||
def test_site_default_language_validation(self):
|
||||
payload = AppSiteUpdatePayload(default_language="en-US")
|
||||
@@ -365,6 +370,7 @@ class TestSiteEndpoints:
|
||||
site.customize_domain = None
|
||||
site.copyright = None
|
||||
site.privacy_policy = None
|
||||
site.input_placeholder = None
|
||||
site.custom_disclaimer = ""
|
||||
site.customize_token_strategy = "not_allow"
|
||||
site.prompt_public = False
|
||||
@@ -377,11 +383,13 @@ class TestSiteEndpoints:
|
||||
)
|
||||
monkeypatch.setattr(site_module, "naive_utc_now", lambda: "now")
|
||||
|
||||
with app.test_request_context("/", json={"title": "My Site"}):
|
||||
with app.test_request_context("/", json={"title": "My Site", "input_placeholder": "Ask me anything"}):
|
||||
result = method(api, SimpleNamespace(id="u1"), app_model=SimpleNamespace(id="app-1"))
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["title"] == "My Site"
|
||||
assert result["input_placeholder"] == "Ask me anything"
|
||||
assert site.input_placeholder == "Ask me anything"
|
||||
|
||||
def test_app_site_access_token_reset(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
api = site_module.AppSiteAccessTokenReset()
|
||||
@@ -398,6 +406,7 @@ class TestSiteEndpoints:
|
||||
site.customize_domain = None
|
||||
site.copyright = None
|
||||
site.privacy_policy = None
|
||||
site.input_placeholder = None
|
||||
site.custom_disclaimer = ""
|
||||
site.customize_token_strategy = "not_allow"
|
||||
site.prompt_public = False
|
||||
|
||||
+1
-1
@@ -222,7 +222,7 @@ def test_get_human_input_form_resolves_runtime_select_options(
|
||||
)
|
||||
|
||||
def mock_get_features(tenant_id: str, exclude_vector_space: bool = False) -> FeatureModel:
|
||||
features = FeatureModel(can_replace_logo=True)
|
||||
features = FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True)
|
||||
return features
|
||||
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -13,6 +13,7 @@ from werkzeug.exceptions import Forbidden
|
||||
from controllers.web.site import AppSiteApi, AppSiteInfo
|
||||
from models import Tenant, TenantStatus
|
||||
from models.model import App, AppMode, CustomizeTokenStrategy, Site
|
||||
from services.feature_service import FeatureModel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -51,6 +52,7 @@ def _create_site(db_session: Session, app_id: str) -> Site:
|
||||
default_language="en",
|
||||
chat_color_theme="light",
|
||||
chat_color_theme_inverted=False,
|
||||
input_placeholder="Ask the app",
|
||||
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
|
||||
code=f"code-{app_id[-6:]}",
|
||||
prompt_public=False,
|
||||
@@ -70,7 +72,7 @@ class TestAppSiteApi:
|
||||
app_model = _create_app(db_session_with_containers, tenant.id)
|
||||
_create_site(db_session_with_containers, app_model.id)
|
||||
end_user = SimpleNamespace(id="eu-1")
|
||||
mock_features.return_value = SimpleNamespace(can_replace_logo=False)
|
||||
mock_features.return_value = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True)
|
||||
|
||||
with app.test_request_context("/site"):
|
||||
result = AppSiteApi().get(app_model, end_user)
|
||||
@@ -78,6 +80,7 @@ class TestAppSiteApi:
|
||||
assert result["app_id"] == app_model.id
|
||||
assert result["plan"] == "basic"
|
||||
assert result["enable_site"] is True
|
||||
assert result["site"]["input_placeholder"] == "Ask the app"
|
||||
|
||||
def test_missing_site_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None:
|
||||
app.config["RESTX_MASK_HEADER"] = "X-Fields"
|
||||
@@ -98,7 +101,7 @@ class TestAppSiteApi:
|
||||
app_model = _create_app(db_session_with_containers, tenant.id)
|
||||
_create_site(db_session_with_containers, app_model.id)
|
||||
end_user = SimpleNamespace(id="eu-1")
|
||||
mock_features.return_value = SimpleNamespace(can_replace_logo=False)
|
||||
mock_features.return_value = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True)
|
||||
|
||||
with app.test_request_context("/site"):
|
||||
with pytest.raises(Forbidden):
|
||||
|
||||
@@ -417,7 +417,7 @@ class TestBillingServiceIsTenantOwnerOrAdmin:
|
||||
account, _ = self._create_account_with_tenant_role(db_session_with_containers, TenantAccountRole.EDITOR)
|
||||
|
||||
with pytest.raises(ValueError, match="Only team owner or team admin can perform this action"):
|
||||
BillingService.is_tenant_owner_or_admin(account)
|
||||
BillingService.is_tenant_owner_or_admin(db_session_with_containers, account)
|
||||
|
||||
def test_is_tenant_owner_or_admin_dataset_operator_raises_error(self, db_session_with_containers: Session) -> None:
|
||||
"""is_tenant_owner_or_admin raises ValueError for DATASET_OPERATOR role."""
|
||||
@@ -426,4 +426,4 @@ class TestBillingServiceIsTenantOwnerOrAdmin:
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Only team owner or team admin can perform this action"):
|
||||
BillingService.is_tenant_owner_or_admin(account)
|
||||
BillingService.is_tenant_owner_or_admin(db_session_with_containers, account)
|
||||
|
||||
+8
-8
@@ -410,7 +410,7 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
)
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
DatasetService.check_dataset_permission(dataset, other_user)
|
||||
DatasetService.check_dataset_permission(dataset, other_user, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_permission_owner_can_access_any_dataset(self, db_session_with_containers: Session):
|
||||
"""Test that tenant owners can access any dataset regardless of permission level."""
|
||||
@@ -423,7 +423,7 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, owner)
|
||||
DatasetService.check_dataset_permission(dataset, owner, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_permission_only_me_creator_can_access(self, db_session_with_containers: Session):
|
||||
"""Test ONLY_ME permission allows only the dataset creator to access."""
|
||||
@@ -433,7 +433,7 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, creator)
|
||||
DatasetService.check_dataset_permission(dataset, creator, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_permission_only_me_others_cannot_access(self, db_session_with_containers: Session):
|
||||
"""Test ONLY_ME permission denies access to non-creators."""
|
||||
@@ -447,7 +447,7 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
)
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
DatasetService.check_dataset_permission(dataset, other)
|
||||
DatasetService.check_dataset_permission(dataset, other, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_permission_all_team_allows_access(self, db_session_with_containers: Session):
|
||||
"""Test ALL_TEAM permission allows any team member to access the dataset."""
|
||||
@@ -460,7 +460,7 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ALL_TEAM
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, member)
|
||||
DatasetService.check_dataset_permission(dataset, member, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_permission_partial_members_with_permission_success(
|
||||
self, db_session_with_containers: Session
|
||||
@@ -483,7 +483,7 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
DatasetPermissionTestDataFactory.create_dataset_permission(dataset.id, user.id, tenant.id)
|
||||
|
||||
# Act (should not raise)
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
DatasetService.check_dataset_permission(dataset, user, db_session_with_containers)
|
||||
|
||||
# Assert
|
||||
permissions = DatasetPermissionService.get_dataset_partial_member_list(dataset.id)
|
||||
@@ -510,7 +510,7 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset"):
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
DatasetService.check_dataset_permission(dataset, user, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_permission_partial_team_creator_can_access(self, db_session_with_containers: Session):
|
||||
"""Test PARTIAL_TEAM permission allows creator to access without explicit permission."""
|
||||
@@ -520,7 +520,7 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.PARTIAL_TEAM
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, creator)
|
||||
DatasetService.check_dataset_permission(dataset, creator, db_session_with_containers)
|
||||
|
||||
|
||||
class TestDatasetServiceCheckDatasetOperatorPermission:
|
||||
|
||||
+5
-5
@@ -237,7 +237,7 @@ class TestDatasetServicePermissionsAndLifecycle:
|
||||
)
|
||||
|
||||
with pytest.raises(NoPermissionError, match="do not have permission"):
|
||||
DatasetService.check_dataset_permission(dataset, outsider)
|
||||
DatasetService.check_dataset_permission(dataset, outsider, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_permission_rejects_only_me_dataset_for_non_creator(
|
||||
self, db_session_with_containers: Session
|
||||
@@ -252,7 +252,7 @@ class TestDatasetServicePermissionsAndLifecycle:
|
||||
)
|
||||
|
||||
with pytest.raises(NoPermissionError, match="do not have permission"):
|
||||
DatasetService.check_dataset_permission(dataset, member)
|
||||
DatasetService.check_dataset_permission(dataset, member, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_permission_rejects_partial_team_user_without_binding(
|
||||
self, db_session_with_containers: Session
|
||||
@@ -267,7 +267,7 @@ class TestDatasetServicePermissionsAndLifecycle:
|
||||
)
|
||||
|
||||
with pytest.raises(NoPermissionError, match="do not have permission"):
|
||||
DatasetService.check_dataset_permission(dataset, member)
|
||||
DatasetService.check_dataset_permission(dataset, member, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_permission_allows_partial_team_creator(self, db_session_with_containers: Session):
|
||||
creator, tenant = DatasetPermissionIntegrationFactory.create_account_with_tenant(
|
||||
@@ -281,7 +281,7 @@ class TestDatasetServicePermissionsAndLifecycle:
|
||||
permission=DatasetPermissionEnum.PARTIAL_TEAM,
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, creator)
|
||||
DatasetService.check_dataset_permission(dataset, creator, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_permission_allows_partial_team_member_with_binding(
|
||||
self, db_session_with_containers: Session
|
||||
@@ -301,7 +301,7 @@ class TestDatasetServicePermissionsAndLifecycle:
|
||||
account_id=member.id,
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, member)
|
||||
DatasetService.check_dataset_permission(dataset, member, db_session_with_containers)
|
||||
|
||||
def test_check_dataset_operator_permission_rejects_only_me_for_non_creator(
|
||||
self, db_session_with_containers: Session
|
||||
|
||||
@@ -109,7 +109,9 @@ def test_request_builder_separates_agent_soul_and_workflow_job_prompt():
|
||||
|
||||
dumped = request.model_dump(mode="json")
|
||||
assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are a careful reviewer."
|
||||
assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Review the previous node output."
|
||||
workflow_job_config = dumped["composition"]["layers"][1]["config"]
|
||||
assert workflow_job_config["user"] == "Review the previous node output."
|
||||
assert not workflow_job_config.get("prefix")
|
||||
assert dumped["composition"]["layers"][2]["config"]["user"] == "Summarize the report."
|
||||
|
||||
|
||||
@@ -162,8 +164,15 @@ def test_request_builder_adds_knowledge_layer_when_configured():
|
||||
run_input = _run_input()
|
||||
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
{
|
||||
"dataset_ids": ["dataset-1"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -174,7 +183,7 @@ def test_request_builder_adds_knowledge_layer_when_configured():
|
||||
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
|
||||
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config)
|
||||
assert knowledge_config.dataset_ids == ["dataset-1"]
|
||||
assert knowledge_config.sets[0].dataset_ids == ["dataset-1"]
|
||||
|
||||
|
||||
def test_request_builder_can_delete_on_exit_for_cleanup_paths():
|
||||
@@ -332,7 +341,7 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell():
|
||||
assert shell_config.env[0].name == "PROJECT_NAME"
|
||||
|
||||
|
||||
def test_workflow_request_builder_binds_shell_to_drive_when_configured():
|
||||
def test_workflow_request_builder_binds_drive_to_shell_when_configured():
|
||||
run_input = _run_input()
|
||||
run_input.include_shell = True
|
||||
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
|
||||
@@ -341,11 +350,11 @@ def test_workflow_request_builder_binds_shell_to_drive_when_configured():
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
layer_names = [layer.name for layer in request.composition.layers]
|
||||
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"drive": DIFY_DRIVE_LAYER_ID,
|
||||
}
|
||||
assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID)
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
|
||||
assert shell_config.agent_stub_drive_ref == "agent-agent-1"
|
||||
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
|
||||
assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
|
||||
|
||||
|
||||
def test_agent_app_request_builder_omits_shell_layer_by_default():
|
||||
@@ -367,7 +376,7 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
|
||||
assert shell_config.env[0].name == "APP_ENV"
|
||||
|
||||
|
||||
def test_agent_app_request_builder_binds_shell_to_drive_when_configured():
|
||||
def test_agent_app_request_builder_binds_drive_to_shell_when_configured():
|
||||
run_input = _agent_app_input(include_shell=True)
|
||||
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
|
||||
|
||||
@@ -375,19 +384,26 @@ def test_agent_app_request_builder_binds_shell_to_drive_when_configured():
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
layer_names = [layer.name for layer in request.composition.layers]
|
||||
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"drive": DIFY_DRIVE_LAYER_ID,
|
||||
}
|
||||
assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID)
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
|
||||
assert shell_config.agent_stub_drive_ref == "agent-agent-1"
|
||||
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
|
||||
assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
|
||||
|
||||
|
||||
def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
|
||||
run_input = _agent_app_input()
|
||||
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
{
|
||||
"dataset_ids": ["dataset-1", "dataset-2"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 2},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 2},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -398,7 +414,7 @@ def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
|
||||
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
|
||||
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config)
|
||||
assert knowledge_config.dataset_ids == ["dataset-1", "dataset-2"]
|
||||
assert knowledge_config.sets[0].dataset_ids == ["dataset-1", "dataset-2"]
|
||||
|
||||
|
||||
# ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ─────
|
||||
|
||||
@@ -149,3 +149,55 @@ def test_generate_specs_is_idempotent(tmp_path):
|
||||
assert [path.name for path in first_paths] == [path.name for path in second_paths]
|
||||
for first_path, second_path in zip(first_paths, second_paths):
|
||||
assert first_path.read_text(encoding="utf-8") == second_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_generate_specs_include_agent_v2_knowledge_set_schema_and_query_enums(tmp_path):
|
||||
module = _load_generate_swagger_specs_module()
|
||||
|
||||
written_paths = module.generate_specs(tmp_path)
|
||||
console_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
||||
payload = json.loads(console_path.read_text(encoding="utf-8"))
|
||||
schemas = payload["components"]["schemas"]
|
||||
|
||||
assert "AgentKnowledgeSetConfig" in schemas
|
||||
assert schemas["AgentSoulKnowledgeConfig"]["properties"]["sets"]["items"]["$ref"] == (
|
||||
"#/components/schemas/AgentKnowledgeSetConfig"
|
||||
)
|
||||
assert schemas["AgentKnowledgeQueryMode"]["enum"] == ["generated_query", "user_query"]
|
||||
|
||||
|
||||
def test_checked_in_agent_v2_knowledge_openapi_and_generated_contracts_are_in_sync():
|
||||
api_dir = Path(__file__).resolve().parents[3]
|
||||
repo_root = api_dir.parent
|
||||
|
||||
markdown = (api_dir / "openapi" / "markdown" / "console-openapi.md").read_text(encoding="utf-8")
|
||||
agent_types = (
|
||||
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "types.gen.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
apps_types = (
|
||||
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "types.gen.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
agent_zod = (
|
||||
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "zod.gen.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
apps_zod = (
|
||||
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "zod.gen.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "#### AgentKnowledgeSetConfig" in markdown
|
||||
assert "#### AgentSoulKnowledgeConfig" in markdown
|
||||
assert "#### AgentKnowledgeQueryMode" in markdown
|
||||
|
||||
for content in (agent_types, apps_types):
|
||||
assert "export type AgentKnowledgeSetConfig = {" in content
|
||||
assert "export type AgentSoulKnowledgeConfig = {" in content
|
||||
assert "AgentKnowledgeQueryMode" in content
|
||||
assert "generated_query" in content
|
||||
assert "user_query" in content
|
||||
|
||||
for content in (agent_zod, apps_zod):
|
||||
assert "export const zAgentKnowledgeSetConfig = z.object({" in content
|
||||
assert "export const zAgentSoulKnowledgeConfig = z.object({" in content
|
||||
assert "zAgentKnowledgeQueryMode = z.enum([" in content
|
||||
assert "generated_query" in content
|
||||
assert "user_query" in content
|
||||
|
||||
@@ -28,11 +28,15 @@ from controllers.console.agent.roster import (
|
||||
AgentAppApi,
|
||||
AgentAppCopyApi,
|
||||
AgentAppListApi,
|
||||
AgentBuildDraftApi,
|
||||
AgentBuildDraftApplyApi,
|
||||
AgentBuildDraftCheckoutApi,
|
||||
AgentDebugConversationRefreshApi,
|
||||
AgentInviteOptionsApi,
|
||||
AgentLogMessagesApi,
|
||||
AgentLogsApi,
|
||||
AgentLogSourcesApi,
|
||||
AgentPublishApi,
|
||||
AgentRosterVersionDetailApi,
|
||||
AgentRosterVersionRestoreApi,
|
||||
AgentRosterVersionsApi,
|
||||
@@ -95,7 +99,7 @@ def _agent_app_composer_response() -> dict:
|
||||
},
|
||||
"active_config_snapshot": _version_response(),
|
||||
"agent_soul": {},
|
||||
"save_options": ["save_to_current_version", "save_as_new_version"],
|
||||
"save_options": ["save_to_current_version"],
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +155,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
|
||||
"/agent/<uuid:agent_id>/composer/candidates",
|
||||
"/agent/<uuid:agent_id>/features",
|
||||
"/agent/<uuid:agent_id>/copy",
|
||||
"/agent/<uuid:agent_id>/publish",
|
||||
"/agent/<uuid:agent_id>/build-draft/checkout",
|
||||
"/agent/<uuid:agent_id>/build-draft",
|
||||
"/agent/<uuid:agent_id>/build-draft/apply",
|
||||
"/agent/<uuid:agent_id>/referencing-workflows",
|
||||
"/agent/<uuid:agent_id>/drive/files",
|
||||
"/agent/<uuid:agent_id>/sandbox/files",
|
||||
@@ -233,6 +241,8 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
lambda _self, **kwargs: {
|
||||
"app-list": SimpleNamespace(
|
||||
id="agent-list",
|
||||
app_id="app-list",
|
||||
backing_app_id=None,
|
||||
role="List role",
|
||||
debug_conversation_id="debug-conversation-list",
|
||||
active_config_snapshot_id=None,
|
||||
@@ -244,6 +254,8 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
"get_app_backing_agent",
|
||||
lambda _self, **kwargs: SimpleNamespace(
|
||||
id="agent-created",
|
||||
app_id="app-created",
|
||||
backing_app_id=None,
|
||||
role="Created role",
|
||||
debug_conversation_id="debug-conversation-created",
|
||||
active_config_snapshot_id=None,
|
||||
@@ -368,6 +380,14 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
|
||||
) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id)
|
||||
agent = SimpleNamespace(
|
||||
id=agent_id,
|
||||
app_id="app-1",
|
||||
backing_app_id=None,
|
||||
role="Resolved role",
|
||||
debug_conversation_id="debug-conversation-detail",
|
||||
active_config_snapshot_id=None,
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -375,15 +395,12 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
|
||||
"get_agent_app_model",
|
||||
lambda _self, **kwargs: app_model,
|
||||
)
|
||||
monkeypatch.setattr(roster_controller, "resolve_agent_runtime_app_model", lambda **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller.db.session, "scalar", lambda _stmt: agent)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"get_app_backing_agent",
|
||||
lambda _self, **kwargs: SimpleNamespace(
|
||||
id=agent_id,
|
||||
role="Resolved role",
|
||||
debug_conversation_id="debug-conversation-detail",
|
||||
active_config_snapshot_id=None,
|
||||
),
|
||||
lambda _self, **kwargs: agent,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
@@ -520,6 +537,129 @@ def test_agent_debug_conversation_refresh_uses_current_user(
|
||||
}
|
||||
|
||||
|
||||
def test_agent_publish_and_build_draft_routes_call_composer_service(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
||||
) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
current_user = SimpleNamespace(id=account_id)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def publish_agent_app_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["publish"] = kwargs
|
||||
return {"result": "success", "active_config_snapshot_id": "version-1"}
|
||||
|
||||
def checkout_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["checkout"] = kwargs
|
||||
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
|
||||
|
||||
def load_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["load"] = kwargs
|
||||
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
|
||||
|
||||
def save_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["save"] = kwargs
|
||||
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
|
||||
|
||||
def apply_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["apply"] = kwargs
|
||||
return {"result": "success", "draft": {"id": "draft-1"}}
|
||||
|
||||
def discard_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["discard"] = kwargs
|
||||
return {"result": "success"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"publish_agent_app_draft",
|
||||
publish_agent_app_draft,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"checkout_agent_app_build_draft",
|
||||
checkout_agent_app_build_draft,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"load_agent_app_build_draft",
|
||||
load_agent_app_build_draft,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"save_agent_app_build_draft",
|
||||
save_agent_app_build_draft,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"apply_agent_app_build_draft",
|
||||
apply_agent_app_build_draft,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"discard_agent_app_build_draft",
|
||||
discard_agent_app_build_draft,
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/publish",
|
||||
json={"version_note": "publish v1"},
|
||||
):
|
||||
published = unwrap(AgentPublishApi.post)(AgentPublishApi(), "tenant-1", current_user, agent_id)
|
||||
assert published["active_config_snapshot_id"] == "version-1"
|
||||
assert captured["publish"] == {
|
||||
"tenant_id": "tenant-1",
|
||||
"agent_id": agent_id,
|
||||
"account_id": account_id,
|
||||
"version_note": "publish v1",
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/checkout",
|
||||
json={"force": True},
|
||||
):
|
||||
checked_out = unwrap(AgentBuildDraftCheckoutApi.post)(
|
||||
AgentBuildDraftCheckoutApi(), "tenant-1", current_user, agent_id
|
||||
)
|
||||
assert checked_out["draft"]["id"] == "build-draft-1"
|
||||
assert captured["checkout"] == {
|
||||
"tenant_id": "tenant-1",
|
||||
"agent_id": agent_id,
|
||||
"account_id": account_id,
|
||||
"force": True,
|
||||
}
|
||||
|
||||
with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft"):
|
||||
loaded = unwrap(AgentBuildDraftApi.get)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
|
||||
assert loaded["draft"]["id"] == "build-draft-1"
|
||||
assert captured["load"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft",
|
||||
json={"variant": "agent_app", "save_strategy": "save_to_current_version", "agent_soul": {}},
|
||||
):
|
||||
saved = unwrap(AgentBuildDraftApi.put)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
|
||||
assert saved["draft"]["id"] == "build-draft-1"
|
||||
assert captured["save"]["tenant_id"] == "tenant-1"
|
||||
assert captured["save"]["agent_id"] == agent_id
|
||||
assert captured["save"]["account_id"] == account_id
|
||||
assert captured["save"]["payload"].variant == ComposerVariant.AGENT_APP
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/apply",
|
||||
method="POST",
|
||||
):
|
||||
applied = unwrap(AgentBuildDraftApplyApi.post)(AgentBuildDraftApplyApi(), "tenant-1", current_user, agent_id)
|
||||
assert applied == {"result": "success", "draft": {"id": "draft-1"}}
|
||||
assert captured["apply"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft",
|
||||
method="DELETE",
|
||||
):
|
||||
discarded = unwrap(AgentBuildDraftApi.delete)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
|
||||
assert discarded == {"result": "success"}
|
||||
assert captured["discard"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
|
||||
|
||||
|
||||
def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -751,7 +891,12 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc
|
||||
restored = unwrap(AgentRosterVersionRestoreApi.post)(
|
||||
AgentRosterVersionRestoreApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id, version_id
|
||||
)
|
||||
assert restored == {"result": "success", "active_config_snapshot_id": version_id}
|
||||
assert restored == {
|
||||
"result": "success",
|
||||
"active_config_snapshot_id": version_id,
|
||||
"draft_config_id": None,
|
||||
"restored_version_id": None,
|
||||
}
|
||||
assert captured_restore == {
|
||||
"tenant_id": "tenant-1",
|
||||
"agent_id": agent_id,
|
||||
@@ -887,7 +1032,7 @@ def test_agent_observability_routes_resolve_app_from_agent_id(
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller, "resolve_agent_runtime_app_model", lambda **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller, "_agent_observability_service", lambda: FakeObservabilityService())
|
||||
|
||||
account = SimpleNamespace(id=account_id, timezone="UTC")
|
||||
@@ -963,10 +1108,11 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
|
||||
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value,
|
||||
"binding": {"binding_type": "roster_agent", "current_snapshot_id": "version-1"},
|
||||
}
|
||||
captured_load: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"load_workflow_composer",
|
||||
lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]),
|
||||
lambda **kwargs: captured_load.update(kwargs) or _workflow_composer_response(node_id=kwargs["node_id"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
@@ -993,8 +1139,12 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
|
||||
},
|
||||
)
|
||||
|
||||
workflow_state = unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1")
|
||||
with app.test_request_context("?snapshot_id=preview-version"):
|
||||
workflow_state = unwrap(WorkflowAgentComposerApi.get)(
|
||||
WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1"
|
||||
)
|
||||
assert workflow_state["node_id"] == "node-1"
|
||||
assert captured_load["snapshot_id"] == "preview-version"
|
||||
with app.test_request_context(json=payload):
|
||||
saved_state = unwrap(WorkflowAgentComposerApi.put)(
|
||||
WorkflowAgentComposerApi(), "tenant-1", account_id, app_model, "node-1"
|
||||
@@ -1092,13 +1242,11 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
"agent_soul": {"prompt": {"system_prompt": "x"}},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(composer_controller, "resolve_agent_app_model", lambda **kwargs: SimpleNamespace(id="app-1"))
|
||||
|
||||
def load_agent_app_composer(**kwargs: object) -> dict:
|
||||
def load_agent_composer(**kwargs: object) -> dict:
|
||||
captured["load"] = kwargs
|
||||
return _agent_app_composer_response()
|
||||
|
||||
def save_agent_app_composer(**kwargs: object) -> dict:
|
||||
def save_agent_composer(**kwargs: object) -> dict:
|
||||
captured["save"] = kwargs
|
||||
return _agent_app_composer_response()
|
||||
|
||||
@@ -1112,13 +1260,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"load_agent_app_composer",
|
||||
load_agent_app_composer,
|
||||
"load_agent_composer",
|
||||
load_agent_composer,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"save_agent_app_composer",
|
||||
save_agent_app_composer,
|
||||
"save_agent_composer",
|
||||
save_agent_composer,
|
||||
)
|
||||
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
||||
monkeypatch.setattr(
|
||||
@@ -1133,13 +1281,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
)
|
||||
|
||||
assert unwrap(AgentComposerApi.get)(AgentComposerApi(), "tenant-1", agent_id)["variant"] == "agent_app"
|
||||
assert cast(dict[str, object], captured["load"])["app_id"] == "app-1"
|
||||
assert cast(dict[str, object], captured["load"])["agent_id"] == agent_id
|
||||
|
||||
with app.test_request_context(json=payload):
|
||||
assert (
|
||||
unwrap(AgentComposerApi.put)(AgentComposerApi(), "tenant-1", account_id, agent_id)["variant"] == "agent_app"
|
||||
)
|
||||
assert cast(dict[str, object], captured["save"])["app_id"] == "app-1"
|
||||
assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id
|
||||
assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), "tenant-1", agent_id) == {
|
||||
"result": "success",
|
||||
"errors": [],
|
||||
@@ -1150,7 +1298,7 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
|
||||
candidates = unwrap(AgentComposerCandidatesApi.get)(AgentComposerCandidatesApi(), "tenant-1", account_id, agent_id)
|
||||
assert candidates["variant"] == "agent_app"
|
||||
assert cast(dict[str, object], captured["candidates"])["app_id"] == "app-1"
|
||||
assert cast(dict[str, object], captured["candidates"])["agent_id"] == agent_id
|
||||
|
||||
|
||||
def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
|
||||
@@ -1172,7 +1320,7 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
|
||||
captured["stop"] = kwargs
|
||||
return {"result": "success"}, 200
|
||||
|
||||
monkeypatch.setattr(completion_controller, "resolve_agent_app_model", resolve_agent_app_model)
|
||||
monkeypatch.setattr(completion_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model)
|
||||
monkeypatch.setattr(completion_controller, "_create_chat_message", create_chat_message)
|
||||
monkeypatch.setattr(completion_controller, "_stop_chat_message", stop_chat_message)
|
||||
|
||||
@@ -1375,7 +1523,7 @@ def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeyp
|
||||
captured["detail"] = kwargs
|
||||
return {"id": message_id}
|
||||
|
||||
monkeypatch.setattr(message_controller, "resolve_agent_app_model", resolve_agent_app_model)
|
||||
monkeypatch.setattr(message_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model)
|
||||
monkeypatch.setattr(message_controller, "_list_chat_messages", list_chat_messages)
|
||||
monkeypatch.setattr(message_controller, "_update_message_feedback", update_message_feedback)
|
||||
monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions)
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
|
||||
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _AgentAppService()
|
||||
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
|
||||
monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model())
|
||||
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", lambda *, tenant_id, agent_id: _app_model())
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"query_params_from_request",
|
||||
@@ -151,7 +151,7 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytes
|
||||
raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404)
|
||||
|
||||
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
|
||||
monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model())
|
||||
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", lambda *, tenant_id, agent_id: _app_model())
|
||||
monkeypatch.setattr(
|
||||
module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".")
|
||||
)
|
||||
|
||||
@@ -64,7 +64,7 @@ def test_list_by_agent_filters_value_pointers_out_of_console_payload():
|
||||
raw = _raw(AgentDriveListByAgentApi.get)
|
||||
with app.test_request_context("/?prefix=pdf-toolkit/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.manifest.return_value = [
|
||||
@@ -105,7 +105,7 @@ def test_skill_list_by_agent_calls_service():
|
||||
raw = _raw(AgentDriveSkillListByAgentApi.get)
|
||||
with app.test_request_context("/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.list_skills.return_value = [
|
||||
@@ -177,7 +177,7 @@ def test_skill_inspect_by_agent_returns_strict_json_response():
|
||||
}
|
||||
with app.test_request_context("/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP),
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.inspect_skill.return_value = payload
|
||||
@@ -256,7 +256,7 @@ def test_preview_by_agent_passes_through_and_maps_errors():
|
||||
raw = _raw(AgentDrivePreviewByAgentApi.get)
|
||||
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.preview.return_value = {
|
||||
@@ -272,7 +272,7 @@ def test_preview_by_agent_passes_through_and_maps_errors():
|
||||
|
||||
with app.test_request_context("/?key=ghost/SKILL.md"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP),
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.preview.side_effect = AgentDriveError(
|
||||
@@ -296,7 +296,7 @@ def test_download_by_agent_returns_signed_url_json():
|
||||
raw = _raw(AgentDriveDownloadByAgentApi.get)
|
||||
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.download_url.return_value = "https://signed.example/zip"
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_upload_by_agent_resolves_app_and_standardizes_into_drive():
|
||||
|
||||
with _file_ctx(files={"file": b"zip-bytes"}):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.SkillStandardizeService") as svc,
|
||||
):
|
||||
svc.return_value.standardize.return_value = {"skill": {"path": "skill-a"}, "manifest": {}}
|
||||
@@ -174,7 +174,7 @@ def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id():
|
||||
upload = SimpleNamespace(id="uf-1", name="sample.pdf")
|
||||
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=ignored"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.console_ns") as ns,
|
||||
patch(f"{_MOD}.db") as db_mock,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
@@ -252,7 +252,7 @@ def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id():
|
||||
raw = _raw(AgentDriveFilesByAgentApi.delete)
|
||||
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
|
||||
@@ -316,7 +316,7 @@ def test_skill_delete_by_agent_uses_agent_route():
|
||||
raw = _raw(AgentSkillByAgentApi.delete)
|
||||
with _json_ctx(method="DELETE", query_string="node_id=ignored"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}]
|
||||
@@ -360,7 +360,7 @@ def test_infer_tools_by_agent_uses_agent_route():
|
||||
raw = _raw(AgentSkillInferToolsByAgentApi.post)
|
||||
with _json_ctx(query_string="node_id=ignored"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.SkillToolInferenceService") as svc,
|
||||
):
|
||||
svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None}
|
||||
|
||||
@@ -379,6 +379,7 @@ def test_app_detail_with_site_includes_nested_serialization(app_models):
|
||||
title="Public Site",
|
||||
icon_type="image",
|
||||
icon="site-icon",
|
||||
input_placeholder="Ask anything",
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
@@ -421,6 +422,7 @@ def test_app_detail_with_site_includes_nested_serialization(app_models):
|
||||
assert serialized["model_config"]["retriever_resource"] == {"enabled": True}
|
||||
assert serialized["deleted_tools"][0]["tool_name"] == "search"
|
||||
assert serialized["site"]["icon_url"] == "signed:site-icon"
|
||||
assert serialized["site"]["input_placeholder"] == "Ask anything"
|
||||
assert serialized["site"]["created_at"] == int(timestamp.timestamp())
|
||||
assert serialized["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
|
||||
assert serialized["bound_agent_id"] == "agent-1"
|
||||
|
||||
@@ -65,7 +65,7 @@ class TestCheckVersionUpdate:
|
||||
assert result.features.can_replace_logo is True
|
||||
assert result.features.model_load_balancing_enabled is False
|
||||
|
||||
def test_http_error_fallback(self):
|
||||
def test_http_error_fallback(self, caplog: pytest.LogCaptureFixture):
|
||||
query = version_module.VersionQuery(current_version="1.0.0")
|
||||
|
||||
with (
|
||||
@@ -79,15 +79,12 @@ class TestCheckVersionUpdate:
|
||||
"get",
|
||||
side_effect=Exception("boom"),
|
||||
),
|
||||
patch.object(
|
||||
version_module.logger,
|
||||
"warning",
|
||||
) as log_warning,
|
||||
caplog.at_level(logging.WARNING, logger="controllers.console.version"),
|
||||
):
|
||||
result = version_module.check_version_update(query)
|
||||
|
||||
assert result.version == "1.0.0"
|
||||
log_warning.assert_called_once()
|
||||
assert "Check update version error" in caplog.text
|
||||
|
||||
def test_new_version_available(self):
|
||||
query = version_module.VersionQuery(current_version="1.0.0")
|
||||
|
||||
@@ -112,6 +112,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
chat_color_theme_inverted=False,
|
||||
copyright=None,
|
||||
privacy_policy=None,
|
||||
input_placeholder="Ask me anything",
|
||||
custom_disclaimer=None,
|
||||
prompt_public=False,
|
||||
show_workflow_steps=True,
|
||||
@@ -132,7 +133,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
monkeypatch.setattr(
|
||||
site_module.FeatureService,
|
||||
"get_features",
|
||||
lambda tenant_id, **_kwargs: SimpleNamespace(can_replace_logo=True),
|
||||
lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True),
|
||||
)
|
||||
|
||||
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
|
||||
@@ -167,6 +168,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"description": "desc",
|
||||
"copyright": None,
|
||||
"privacy_policy": None,
|
||||
"input_placeholder": "Ask me anything",
|
||||
"custom_disclaimer": None,
|
||||
"default_language": "en",
|
||||
"prompt_public": False,
|
||||
@@ -186,6 +188,72 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
|
||||
|
||||
|
||||
def test_serialize_app_site_payload_masks_paid_webapp_fields_when_feature_disabled(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Runtime site payload hides paid-only fields for plans that cannot use them."""
|
||||
|
||||
tenant = SimpleNamespace(id="tenant-1", plan="sandbox", custom_config_dict={})
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
|
||||
site_model = SimpleNamespace(
|
||||
title="My Site",
|
||||
icon_type="emoji",
|
||||
icon="robot",
|
||||
icon_background="#fff",
|
||||
description="desc",
|
||||
default_language="en",
|
||||
chat_color_theme="light",
|
||||
chat_color_theme_inverted=False,
|
||||
copyright="Dify",
|
||||
privacy_policy=None,
|
||||
input_placeholder="Ask me anything",
|
||||
custom_disclaimer=None,
|
||||
prompt_public=False,
|
||||
show_workflow_steps=True,
|
||||
use_icon_as_answer_icon=False,
|
||||
)
|
||||
features = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=False)
|
||||
features.billing.enabled = True
|
||||
monkeypatch.setattr(site_module.FeatureService, "get_features", lambda tenant_id, **_kwargs: features)
|
||||
|
||||
payload = site_module.serialize_app_site_payload(app_model, site_model, end_user_id=None)
|
||||
|
||||
assert payload["site"]["copyright"] is None
|
||||
assert payload["site"]["input_placeholder"] is None
|
||||
|
||||
|
||||
def test_serialize_app_site_payload_keeps_paid_webapp_fields_when_billing_disabled(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Self-hosted community runtimes keep site fields because billing is not enforcing the paid gate."""
|
||||
|
||||
tenant = SimpleNamespace(id="tenant-1", plan="basic", custom_config_dict={})
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
|
||||
site_model = SimpleNamespace(
|
||||
title="My Site",
|
||||
icon_type="emoji",
|
||||
icon="robot",
|
||||
icon_background="#fff",
|
||||
description="desc",
|
||||
default_language="en",
|
||||
chat_color_theme="light",
|
||||
chat_color_theme_inverted=False,
|
||||
copyright="Dify",
|
||||
privacy_policy=None,
|
||||
input_placeholder="Ask me anything",
|
||||
custom_disclaimer=None,
|
||||
prompt_public=False,
|
||||
show_workflow_steps=True,
|
||||
use_icon_as_answer_icon=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
site_module.FeatureService,
|
||||
"get_features",
|
||||
lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=False, webapp_copyright_enabled=False),
|
||||
)
|
||||
|
||||
payload = site_module.serialize_app_site_payload(app_model, site_model, end_user_id=None)
|
||||
|
||||
assert payload["site"]["copyright"] == "Dify"
|
||||
assert payload["site"]["input_placeholder"] == "Ask me anything"
|
||||
|
||||
|
||||
def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""GET returns variable-backed select options resolved from runtime state."""
|
||||
|
||||
@@ -256,6 +324,7 @@ def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, a
|
||||
chat_color_theme_inverted=False,
|
||||
copyright=None,
|
||||
privacy_policy=None,
|
||||
input_placeholder="Ask me anything",
|
||||
custom_disclaimer=None,
|
||||
prompt_public=False,
|
||||
show_workflow_steps=True,
|
||||
@@ -380,6 +449,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
|
||||
chat_color_theme_inverted=False,
|
||||
copyright=None,
|
||||
privacy_policy=None,
|
||||
input_placeholder="Ask me anything",
|
||||
custom_disclaimer=None,
|
||||
prompt_public=False,
|
||||
show_workflow_steps=True,
|
||||
@@ -397,7 +467,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
|
||||
monkeypatch.setattr(
|
||||
site_module.FeatureService,
|
||||
"get_features",
|
||||
lambda tenant_id, **_kwargs: SimpleNamespace(can_replace_logo=True),
|
||||
lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True),
|
||||
)
|
||||
|
||||
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
|
||||
@@ -432,6 +502,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
|
||||
"description": "desc",
|
||||
"copyright": None,
|
||||
"privacy_policy": None,
|
||||
"input_placeholder": "Ask me anything",
|
||||
"custom_disclaimer": None,
|
||||
"default_language": "en",
|
||||
"prompt_public": False,
|
||||
|
||||
@@ -69,7 +69,7 @@ class TestGenerateSuccess:
|
||||
def test_runtime_session_snapshot_id_is_stable_for_debugger_only(self):
|
||||
assert (
|
||||
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1")
|
||||
is None
|
||||
== "snap-1"
|
||||
)
|
||||
assert (
|
||||
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.WEB_APP, snapshot_id="snap-1")
|
||||
@@ -111,7 +111,12 @@ class TestGenerateSuccess:
|
||||
|
||||
assert result == {"result": "ok"}
|
||||
thread_obj.start.assert_called_once()
|
||||
generator._resolve_agent.assert_called_once_with(app_model)
|
||||
generator._resolve_agent.assert_called_once_with(
|
||||
app_model,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=user,
|
||||
)
|
||||
|
||||
def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture):
|
||||
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
|
||||
|
||||
@@ -46,6 +46,13 @@ class _FakeCredentialsProvider:
|
||||
return {"openai_api_key": "sk-test"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
|
||||
)
|
||||
|
||||
|
||||
class _NoToolsBuilder:
|
||||
def build(self, **kwargs):
|
||||
del kwargs
|
||||
|
||||
@@ -14,6 +14,7 @@ import pytest
|
||||
|
||||
from core.app.apps.agent_app import app_generator as gen_mod
|
||||
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
_SOUL_DICT = {
|
||||
"model": {
|
||||
@@ -84,14 +85,24 @@ class TestResolveAgent:
|
||||
_patch_session(monkeypatch, [bound_agent, inner_agent, snapshot])
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
|
||||
|
||||
agent, snap, soul = AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type]
|
||||
agent, snap, soul = AgentAppGenerator()._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
assert agent is inner_agent
|
||||
assert snap is snapshot
|
||||
assert agent is bound_agent
|
||||
assert snap == snapshot.id
|
||||
assert soul.model is not None
|
||||
|
||||
def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_session(monkeypatch, [None])
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
|
||||
with pytest.raises(AgentAppGeneratorError, match="has no bound Agent"):
|
||||
AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type]
|
||||
AgentAppGenerator()._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
@@ -85,6 +85,49 @@ class _NoToolsBuilder:
|
||||
del kwargs
|
||||
|
||||
|
||||
def _mock_empty_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
|
||||
|
||||
def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"size": 123,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": "hash-1",
|
||||
"created_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
|
||||
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
|
||||
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False},
|
||||
{"key": "files/sample.pdf", "is_skill": False},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_default_agent_app_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_mock_empty_drive_catalog(monkeypatch)
|
||||
|
||||
|
||||
def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuildContext:
|
||||
dify_context = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
@@ -128,7 +171,15 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
req = result.request
|
||||
assert req.purpose == "agent_app"
|
||||
names = [layer.name for layer in req.composition.layers]
|
||||
assert names == ["agent_soul_prompt", "agent_app_user_prompt", "execution_context", "history", "llm"]
|
||||
assert names == [
|
||||
"agent_soul_prompt",
|
||||
"agent_app_user_prompt",
|
||||
"execution_context",
|
||||
"shell",
|
||||
"drive",
|
||||
"history",
|
||||
"llm",
|
||||
]
|
||||
# plugin_id / provider normalized for plugin-daemon transport.
|
||||
llm = next(layer for layer in req.composition.layers if layer.name == "llm")
|
||||
assert llm.config.plugin_id == "langgenius/openai"
|
||||
@@ -169,12 +220,19 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query_config": {
|
||||
"top_k": 3,
|
||||
"score_threshold": 0.5,
|
||||
"score_threshold_enabled": False,
|
||||
},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 3,
|
||||
"score_threshold": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -189,10 +247,12 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
assert knowledge.type == "dify.knowledge_base"
|
||||
assert knowledge.deps == {"execution_context": "execution_context"}
|
||||
dumped_config = knowledge.config.model_dump(mode="json", by_alias=True)
|
||||
assert dumped_config["dataset_ids"] == ["dataset-1", "dataset-2"]
|
||||
assert dumped_config["retrieval"]["mode"] == "multiple"
|
||||
assert dumped_config["retrieval"]["top_k"] == 3
|
||||
assert dumped_config["retrieval"]["score_threshold"] == 0.0
|
||||
knowledge_set = dumped_config["sets"][0]
|
||||
assert [dataset["id"] for dataset in knowledge_set["datasets"]] == ["dataset-1", "dataset-2"]
|
||||
assert knowledge_set["query"] == {"mode": "generated_query", "value": None}
|
||||
assert knowledge_set["retrieval"]["mode"] == "multiple"
|
||||
assert knowledge_set["retrieval"]["top_k"] == 3
|
||||
assert knowledge_set["retrieval"]["score_threshold"] == 0.0
|
||||
|
||||
def test_build_raises_when_model_missing(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
@@ -242,9 +302,26 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
|
||||
|
||||
def _soul_with_model_and_skill() -> AgentSoulConfig:
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"
|
||||
return soul
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"model": {
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "langgenius/openai/openai",
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
"prompt": {"system_prompt": "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"},
|
||||
"files": {
|
||||
"skills": [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestAgentAppDriveLayer:
|
||||
@@ -252,28 +329,7 @@ class TestAgentAppDriveLayer:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"size": 1,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
|
||||
{"key": "tender-analyzer/SKILL.md", "is_skill": True}
|
||||
],
|
||||
)
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
@@ -283,27 +339,20 @@ class TestAgentAppDriveLayer:
|
||||
|
||||
drive = next(layer for layer in result.request.composition.layers if layer.name == "drive")
|
||||
assert drive.type == "dify.drive"
|
||||
assert drive.deps == {"execution_context": "execution_context"}
|
||||
assert drive.deps == {"shell": DIFY_SHELL_LAYER_ID}
|
||||
assert drive.config.drive_ref == "agent-agent-1"
|
||||
assert [skill.skill_md_key for skill in drive.config.skills] == ["tender-analyzer/SKILL.md"]
|
||||
assert drive.config.mentioned_skill_keys == ["tender-analyzer/SKILL.md"]
|
||||
# injected right after execution_context, mirroring the workflow surface
|
||||
# shell enters first; drive uses that shell to materialize mentioned targets.
|
||||
names = [layer.name for layer in result.request.composition.layers]
|
||||
assert names.index("drive") == names.index("execution_context") + 1
|
||||
assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 1
|
||||
assert names.index("drive") == names.index(DIFY_SHELL_LAYER_ID) + 1
|
||||
|
||||
def test_drive_layer_injected_with_empty_catalog_and_shell_depends_on_it(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_drive_layer_injected_with_empty_catalog_and_drive_depends_on_shell(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
@@ -314,12 +363,14 @@ class TestAgentAppDriveLayer:
|
||||
layers = {layer.name: layer for layer in result.request.composition.layers}
|
||||
assert layers["drive"].config.drive_ref == "agent-agent-1"
|
||||
assert layers["drive"].config.skills == []
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": "execution_context",
|
||||
"drive": "drive",
|
||||
}
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": "execution_context"}
|
||||
assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref == "agent-agent-1"
|
||||
assert layers["drive"].deps == {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
def test_no_drive_layer_when_flag_disabled(self):
|
||||
def test_no_drive_layer_when_flag_disabled(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
@@ -334,29 +385,7 @@ class TestAgentAppDriveLayer:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"size": 1,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
|
||||
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
|
||||
{"key": "files/sample.pdf", "is_skill": False},
|
||||
],
|
||||
)
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = (
|
||||
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
|
||||
@@ -379,14 +408,7 @@ class TestAgentAppDriveLayer:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = (
|
||||
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
|
||||
@@ -407,29 +429,7 @@ class TestAgentAppDriveLayer:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"size": 1,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
|
||||
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
|
||||
{"key": "files/sample.pdf", "is_skill": False},
|
||||
],
|
||||
)
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = (
|
||||
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]"
|
||||
@@ -452,14 +452,6 @@ class TestAgentAppDriveLayer:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = (
|
||||
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
|
||||
|
||||
@@ -394,13 +394,15 @@ def test_switch_preferred_provider_type_returns_early_when_no_change_or_unsuppor
|
||||
configuration = _build_provider_configuration()
|
||||
|
||||
with patch("core.entities.provider_configuration.Session") as mock_session_cls:
|
||||
configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
assert changed is False
|
||||
mock_session_cls.assert_not_called()
|
||||
|
||||
configuration.preferred_provider_type = ProviderType.CUSTOM
|
||||
configuration.system_configuration.enabled = False
|
||||
with patch("core.entities.provider_configuration.Session") as mock_session_cls:
|
||||
configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
assert changed is False
|
||||
mock_session_cls.assert_not_called()
|
||||
|
||||
|
||||
@@ -411,10 +413,13 @@ def test_switch_preferred_provider_type_updates_existing_record_with_session() -
|
||||
existing_record = SimpleNamespace(preferred_provider_type="custom")
|
||||
session.execute.return_value.scalars.return_value.first.return_value = existing_record
|
||||
|
||||
configuration.switch_preferred_provider_type(ProviderType.SYSTEM, session=session)
|
||||
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM, session=session)
|
||||
|
||||
assert changed is True
|
||||
assert existing_record.preferred_provider_type == ProviderType.SYSTEM
|
||||
session.commit.assert_called_once()
|
||||
mock_invalidate.assert_not_called()
|
||||
|
||||
|
||||
def test_switch_preferred_provider_type_creates_record_when_missing() -> None:
|
||||
@@ -423,10 +428,13 @@ def test_switch_preferred_provider_type_creates_record_when_missing() -> None:
|
||||
session = Mock()
|
||||
session.execute.return_value.scalars.return_value.first.return_value = None
|
||||
|
||||
configuration.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
|
||||
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
|
||||
|
||||
assert changed is True
|
||||
assert session.add.call_count == 1
|
||||
session.commit.assert_called_once()
|
||||
mock_invalidate.assert_not_called()
|
||||
|
||||
|
||||
def test_get_model_type_instance_and_schema_delegate_to_factory() -> None:
|
||||
@@ -1022,13 +1030,14 @@ def test_update_load_balancing_configs_updates_all_matching_configs() -> None:
|
||||
credential_record = SimpleNamespace(encrypted_config='{"api_key":"enc"}', credential_name="API KEY 3")
|
||||
|
||||
with patch("core.entities.provider_configuration.ProviderCredentialsCache") as mock_cache:
|
||||
configuration._update_load_balancing_configs_with_credential(
|
||||
changed = configuration._update_load_balancing_configs_with_credential(
|
||||
credential_id="cred-1",
|
||||
credential_record=credential_record,
|
||||
credential_source=CredentialSourceType.PROVIDER,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert changed is True
|
||||
assert lb_config.encrypted_config == '{"api_key":"enc"}'
|
||||
assert lb_config.name == "API KEY 3"
|
||||
mock_cache.return_value.delete.assert_called_once()
|
||||
@@ -1040,13 +1049,14 @@ def test_update_load_balancing_configs_returns_when_no_matching_configs() -> Non
|
||||
session = Mock()
|
||||
session.execute.return_value.scalars.return_value.all.return_value = []
|
||||
|
||||
configuration._update_load_balancing_configs_with_credential(
|
||||
changed = configuration._update_load_balancing_configs_with_credential(
|
||||
credential_id="cred-1",
|
||||
credential_record=SimpleNamespace(encrypted_config="{}", credential_name="Main"),
|
||||
credential_source=CredentialSourceType.PROVIDER,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert changed is False
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
@@ -1478,12 +1488,15 @@ def test_model_load_balancing_enable_disable_and_switch_preferred_provider_type_
|
||||
switch_session = Mock()
|
||||
with _patched_session(switch_session):
|
||||
switch_session.execute.return_value.scalars.return_value.first.return_value = None
|
||||
configuration.switch_preferred_provider_type(ProviderType.CUSTOM)
|
||||
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.CUSTOM)
|
||||
assert changed is True
|
||||
assert any(
|
||||
call.args and call.args[0].__class__.__name__ == "TenantPreferredModelProvider"
|
||||
for call in switch_session.add.call_args_list
|
||||
)
|
||||
switch_session.commit.assert_called()
|
||||
mock_invalidate.assert_called_once_with(preferred_model_providers=True)
|
||||
|
||||
|
||||
def test_system_and_custom_provider_model_helpers_cover_remaining_skip_paths() -> None:
|
||||
|
||||
@@ -5,10 +5,17 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from core.entities.provider_entities import ModelSettings
|
||||
from core.provider_manager import ProviderManager
|
||||
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.provider import LoadBalancingModelConfig, ProviderModelSetting, TenantDefaultModel
|
||||
from models.provider import (
|
||||
LoadBalancingModelConfig,
|
||||
Provider,
|
||||
ProviderCredential,
|
||||
ProviderModelSetting,
|
||||
ProviderType,
|
||||
TenantDefaultModel,
|
||||
)
|
||||
from models.provider_ids import ModelProviderID
|
||||
|
||||
|
||||
@@ -23,6 +30,43 @@ def _build_session_context(session: Mock) -> MagicMock:
|
||||
return session_cm
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self) -> None:
|
||||
self.store: dict[str, str] = {}
|
||||
self.expirations: dict[str, int] = {}
|
||||
|
||||
def get(self, key: str):
|
||||
return self.store.get(key)
|
||||
|
||||
def set(self, key: str, value: str, *, ex: int | None = None) -> None:
|
||||
self.store[key] = value
|
||||
if ex is not None:
|
||||
self.expirations[key] = ex
|
||||
|
||||
def setex(self, key: str, time: int, value: str) -> None:
|
||||
self.store[key] = value
|
||||
self.expirations[key] = time
|
||||
|
||||
def incr(self, key: str) -> int:
|
||||
value = int(self.store.get(key, "0")) + 1
|
||||
self.store[key] = str(value)
|
||||
return value
|
||||
|
||||
def expire(self, key: str, time: int) -> None:
|
||||
self.expirations[key] = time
|
||||
|
||||
|
||||
class _FakeScalarResult:
|
||||
def __init__(self, values: list[object]) -> None:
|
||||
self._values = values
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._values)
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self._values
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider_entity():
|
||||
mock_entity = Mock()
|
||||
@@ -309,6 +353,7 @@ def test_get_configurations_uses_injected_runtime_and_adds_provider_aliases(mock
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_credentials", return_value={}),
|
||||
patch("core.provider_manager.ModelProviderFactory") as mock_factory_cls,
|
||||
):
|
||||
mock_factory_cls.return_value.get_providers.return_value = []
|
||||
@@ -361,6 +406,7 @@ def test_get_configurations_binds_manager_runtime_to_provider_configuration(
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_credentials", return_value={}),
|
||||
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
@@ -388,6 +434,7 @@ def test_get_configurations_reuses_cached_result_for_same_tenant(mocker: MockerF
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_credentials", return_value={}),
|
||||
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
@@ -424,6 +471,7 @@ def test_clear_configurations_cache_rebuilds_requested_tenant(mocker: MockerFixt
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_credentials", return_value={}),
|
||||
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
@@ -578,19 +626,162 @@ def test_get_all_providers_normalizes_provider_names_with_model_provider_id() ->
|
||||
assert list(result[str(ModelProviderID("langgenius/gemini/google"))]) == [gemini_provider]
|
||||
|
||||
|
||||
def test_get_all_providers_attaches_active_credentials() -> None:
|
||||
provider = Provider(
|
||||
tenant_id="tenant-id",
|
||||
provider_name="openai",
|
||||
provider_type=ProviderType.CUSTOM,
|
||||
is_valid=True,
|
||||
credential_id="credential-id",
|
||||
)
|
||||
provider.id = "provider-id"
|
||||
credential = ProviderCredential(
|
||||
tenant_id="tenant-id",
|
||||
provider_name="openai",
|
||||
credential_name="primary",
|
||||
encrypted_config='{"api_key": "secret"}',
|
||||
)
|
||||
credential.id = "credential-id"
|
||||
session = Mock()
|
||||
session.scalars.side_effect = [
|
||||
_FakeScalarResult([provider]),
|
||||
_FakeScalarResult([credential]),
|
||||
]
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
|
||||
):
|
||||
result = ProviderManager._get_all_providers("tenant-id")
|
||||
|
||||
assert session.scalars.call_count == 2
|
||||
assert result[str(ModelProviderID("openai"))][0].credential_name == "primary"
|
||||
assert result[str(ModelProviderID("openai"))][0].encrypted_config == '{"api_key": "secret"}'
|
||||
|
||||
|
||||
def test_invalidate_configurations_cache_bumps_selected_source_version() -> None:
|
||||
fake_redis = _FakeRedis()
|
||||
|
||||
with patch("core.provider_manager.redis_client", fake_redis):
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
"tenant-id",
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,),
|
||||
)
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
"tenant-id",
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,),
|
||||
)
|
||||
|
||||
assert fake_redis.store["provider_configurations:tenant:tenant-id:source:provider_credentials:version"] == "2"
|
||||
assert fake_redis.expirations["provider_configurations:tenant:tenant-id:source:provider_credentials:version"] == 360
|
||||
assert "provider_configurations:tenant:tenant-id:source:provider_models:version" not in fake_redis.store
|
||||
|
||||
|
||||
def test_provider_model_credentials_cache_returns_cache_entries() -> None:
|
||||
fake_redis = _FakeRedis()
|
||||
credential_record = SimpleNamespace(
|
||||
id="credential-id",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
credential_name="primary",
|
||||
)
|
||||
session = Mock()
|
||||
session.scalars.return_value = [credential_record]
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.redis_client", fake_redis),
|
||||
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
|
||||
):
|
||||
first = ProviderManager._get_all_provider_model_credentials("tenant-id")
|
||||
second = ProviderManager._get_all_provider_model_credentials("tenant-id")
|
||||
|
||||
assert session.scalars.call_count == 1
|
||||
version_key = "provider_configurations:tenant:tenant-id:source:provider_model_credentials:version"
|
||||
assert fake_redis.expirations[version_key] == 360
|
||||
assert first["openai"][0] is not credential_record
|
||||
assert second["openai"][0].credential_name == "primary"
|
||||
assert second["openai"][0].model_type == ModelType.LLM
|
||||
|
||||
|
||||
def test_provider_configuration_cache_skips_write_when_version_changes_during_load() -> None:
|
||||
fake_redis = _FakeRedis()
|
||||
version_key = "provider_configurations:tenant:tenant-id:source:provider_model_credentials:version"
|
||||
credential_record = SimpleNamespace(
|
||||
id="credential-id",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
credential_name="primary",
|
||||
)
|
||||
session = Mock()
|
||||
|
||||
def load_records(_stmt):
|
||||
fake_redis.incr(version_key)
|
||||
return [credential_record]
|
||||
|
||||
session.scalars.side_effect = load_records
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.redis_client", fake_redis),
|
||||
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
|
||||
):
|
||||
result = ProviderManager._get_all_provider_model_credentials("tenant-id")
|
||||
|
||||
assert fake_redis.store[version_key] == "1"
|
||||
assert "provider_configurations:tenant:tenant-id:source:provider_model_credentials:v:0" not in fake_redis.store
|
||||
assert "provider_configurations:tenant:tenant-id:source:provider_model_credentials:v:1" not in fake_redis.store
|
||||
assert result["openai"][0].credential_name == "primary"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method_name",
|
||||
[
|
||||
"_get_all_provider_models",
|
||||
"_get_all_provider_model_settings",
|
||||
"_get_all_provider_model_credentials",
|
||||
"_get_all_provider_credentials",
|
||||
],
|
||||
)
|
||||
def test_provider_grouping_helpers_group_records_by_provider_name(method_name: str) -> None:
|
||||
def build_record(provider_name: str, index: int):
|
||||
match method_name:
|
||||
case "_get_all_provider_models":
|
||||
return SimpleNamespace(
|
||||
id=f"model-{index}",
|
||||
provider_name=provider_name,
|
||||
model_name=f"model-{index}",
|
||||
model_type=ModelType.LLM,
|
||||
credential_id=None,
|
||||
)
|
||||
case "_get_all_provider_model_settings":
|
||||
return SimpleNamespace(
|
||||
provider_name=provider_name,
|
||||
model_name=f"model-{index}",
|
||||
model_type=ModelType.LLM,
|
||||
enabled=True,
|
||||
load_balancing_enabled=False,
|
||||
)
|
||||
case "_get_all_provider_model_credentials":
|
||||
return SimpleNamespace(
|
||||
id=f"model-credential-{index}",
|
||||
provider_name=provider_name,
|
||||
model_name=f"model-{index}",
|
||||
model_type=ModelType.LLM,
|
||||
credential_name=f"credential-{index}",
|
||||
)
|
||||
case "_get_all_provider_credentials":
|
||||
return SimpleNamespace(
|
||||
id=f"credential-{index}",
|
||||
provider_name=provider_name,
|
||||
credential_name=f"credential-{index}",
|
||||
)
|
||||
case _:
|
||||
raise AssertionError(f"Unexpected method: {method_name}")
|
||||
|
||||
session = Mock()
|
||||
openai_primary = SimpleNamespace(provider_name="openai")
|
||||
openai_secondary = SimpleNamespace(provider_name="openai")
|
||||
anthropic_record = SimpleNamespace(provider_name="anthropic")
|
||||
openai_primary = build_record("openai", 1)
|
||||
openai_secondary = build_record("openai", 2)
|
||||
anthropic_record = build_record("anthropic", 3)
|
||||
session.scalars.return_value = [openai_primary, openai_secondary, anthropic_record]
|
||||
|
||||
with (
|
||||
@@ -598,14 +789,14 @@ def test_provider_grouping_helpers_group_records_by_provider_name(method_name: s
|
||||
):
|
||||
result = getattr(ProviderManager, method_name)("tenant-id")
|
||||
|
||||
assert list(result["openai"]) == [openai_primary, openai_secondary]
|
||||
assert list(result["anthropic"]) == [anthropic_record]
|
||||
assert [record.provider_name for record in result["openai"]] == ["openai", "openai"]
|
||||
assert [record.provider_name for record in result["anthropic"]] == ["anthropic"]
|
||||
|
||||
|
||||
def test_get_all_preferred_model_providers_returns_mapping_by_provider_name() -> None:
|
||||
session = Mock()
|
||||
openai_preference = SimpleNamespace(provider_name="openai")
|
||||
anthropic_preference = SimpleNamespace(provider_name="anthropic")
|
||||
openai_preference = SimpleNamespace(provider_name="openai", preferred_provider_type=ProviderType.SYSTEM)
|
||||
anthropic_preference = SimpleNamespace(provider_name="anthropic", preferred_provider_type=ProviderType.CUSTOM)
|
||||
session.scalars.return_value = [openai_preference, anthropic_preference]
|
||||
|
||||
with (
|
||||
@@ -613,10 +804,8 @@ def test_get_all_preferred_model_providers_returns_mapping_by_provider_name() ->
|
||||
):
|
||||
result = ProviderManager._get_all_preferred_model_providers("tenant-id")
|
||||
|
||||
assert result == {
|
||||
"openai": openai_preference,
|
||||
"anthropic": anthropic_preference,
|
||||
}
|
||||
assert result["openai"].preferred_provider_type == ProviderType.SYSTEM
|
||||
assert result["anthropic"].preferred_provider_type == ProviderType.CUSTOM
|
||||
|
||||
|
||||
def test_get_all_provider_load_balancing_configs_returns_empty_when_cached_flag_is_disabled() -> None:
|
||||
@@ -634,8 +823,30 @@ def test_get_all_provider_load_balancing_configs_returns_empty_when_cached_flag_
|
||||
|
||||
def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_configs() -> None:
|
||||
session = Mock()
|
||||
openai_config = SimpleNamespace(provider_name="openai")
|
||||
anthropic_config = SimpleNamespace(provider_name="anthropic")
|
||||
openai_config = SimpleNamespace(
|
||||
id="lb-1",
|
||||
tenant_id="tenant-id",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
name="primary",
|
||||
encrypted_config=None,
|
||||
credential_id=None,
|
||||
credential_source_type=None,
|
||||
enabled=True,
|
||||
)
|
||||
anthropic_config = SimpleNamespace(
|
||||
id="lb-2",
|
||||
tenant_id="tenant-id",
|
||||
provider_name="anthropic",
|
||||
model_name="claude",
|
||||
model_type=ModelType.LLM,
|
||||
name="primary",
|
||||
encrypted_config=None,
|
||||
credential_id=None,
|
||||
credential_source_type=None,
|
||||
enabled=True,
|
||||
)
|
||||
session.scalars.return_value = [openai_config, anthropic_config]
|
||||
|
||||
with (
|
||||
@@ -649,6 +860,6 @@ def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_conf
|
||||
):
|
||||
result = ProviderManager._get_all_provider_load_balancing_configs("tenant-id")
|
||||
|
||||
mock_setex.assert_called_once_with("tenant:tenant-id:model_load_balancing_enabled", 120, "True")
|
||||
assert list(result["openai"]) == [openai_config]
|
||||
assert list(result["anthropic"]) == [anthropic_config]
|
||||
mock_setex.assert_any_call("tenant:tenant-id:model_load_balancing_enabled", 120, "True")
|
||||
assert [record.provider_name for record in result["openai"]] == ["openai"]
|
||||
assert [record.provider_name for record in result["anthropic"]] == ["anthropic"]
|
||||
|
||||
@@ -2,6 +2,7 @@ from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.layers.ask_human import AskHumanToolResult
|
||||
from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData
|
||||
@@ -50,6 +51,13 @@ class FakeCredentialsProvider:
|
||||
return {"api_key": "secret-key"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
|
||||
)
|
||||
|
||||
|
||||
def _restored_file(*, transfer_method: FileTransferMethod, reference: str) -> File:
|
||||
return File(
|
||||
type=FileType.DOCUMENT,
|
||||
@@ -245,6 +253,23 @@ def _node(
|
||||
)
|
||||
|
||||
|
||||
def test_extract_variable_selector_to_variable_mapping_uses_frontend_agent_task_markers():
|
||||
mapping = DifyAgentNode._extract_variable_selector_to_variable_mapping(
|
||||
graph_config={},
|
||||
node_id="agent-node",
|
||||
node_data={
|
||||
"agent_task": (
|
||||
"Review {{#previous-node.report#}}, ignore {{#sys.query#}}, "
|
||||
"ignore [§node_output:legacy-node.output:LEGACY§], then use {{#previous-node.report#}} again."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert mapping == {
|
||||
"agent-node.previous-node.report": ["previous-node", "report"],
|
||||
}
|
||||
|
||||
|
||||
def test_agent_node_run_maps_successful_agent_backend_run_to_node_result():
|
||||
events = list(_node()._run())
|
||||
|
||||
|
||||
+366
-61
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from typing import cast
|
||||
|
||||
@@ -9,6 +10,7 @@ from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LA
|
||||
from clients.agent_backend import DIFY_EXECUTION_CONTEXT_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID
|
||||
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from core.workflow.nodes.agent_v2.plugin_tools_builder import WorkflowAgentPluginToolsBuilder
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
WorkflowAgentRuntimeBuildContext,
|
||||
@@ -16,7 +18,8 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
WorkflowAgentRuntimeRequestBuildError,
|
||||
build_shell_layer_config,
|
||||
)
|
||||
from graphon.variables.segments import StringSegment
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
|
||||
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
|
||||
from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
@@ -36,6 +39,13 @@ class FakeCredentialsProvider:
|
||||
return {"api_key": "secret-key"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
|
||||
)
|
||||
|
||||
|
||||
class CapturingCredentialsProvider:
|
||||
def __init__(self) -> None:
|
||||
self.provider_name: str | None = None
|
||||
@@ -165,6 +175,27 @@ def _context() -> WorkflowAgentRuntimeBuildContext:
|
||||
)
|
||||
|
||||
|
||||
def _request_layers(result) -> dict[str, dict[str, object]]:
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
return {layer["name"]: layer for layer in dumped["composition"]["layers"]}
|
||||
|
||||
|
||||
def _workflow_user_prompt(result) -> str:
|
||||
from clients.agent_backend import WORKFLOW_USER_PROMPT_LAYER_ID
|
||||
|
||||
layer = _request_layers(result)[WORKFLOW_USER_PROMPT_LAYER_ID]
|
||||
return cast(str, layer["config"]["user"])
|
||||
|
||||
|
||||
def _previous_node_prompt_payload(result, selector: str) -> object:
|
||||
prefix = f" - {selector}: "
|
||||
user_prompt = _workflow_user_prompt(result)
|
||||
for line in user_prompt.splitlines():
|
||||
if line.startswith(prefix):
|
||||
return json.loads(line.removeprefix(prefix))
|
||||
raise AssertionError(f"missing prompt payload for {selector}")
|
||||
|
||||
|
||||
def test_builds_create_run_request_from_agent_soul_and_node_job():
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context())
|
||||
|
||||
@@ -177,11 +208,14 @@ def test_builds_create_run_request_from_agent_soul_and_node_job():
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "debugger"
|
||||
assert dumped["idempotency_key"] == "run-1:node-exec-1"
|
||||
assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are careful."
|
||||
assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Use the previous output."
|
||||
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
|
||||
assert dumped["composition"]["layers"][1]["config"]["user"] == "Use the previous output."
|
||||
assert "Agent task for this workflow run:" not in dumped["composition"]["layers"][2]["config"]["user"]
|
||||
assert "User query: Summarize the report." in dumped["composition"]["layers"][2]["config"]["user"]
|
||||
assert "Previous node outputs:" not in dumped["composition"]["layers"][2]["config"]["user"]
|
||||
assert dumped["composition"]["layers"][-1]["config"]["json_schema"]["properties"]["summary"]["type"] == "string"
|
||||
assert DIFY_AGENT_HISTORY_LAYER_ID in layers
|
||||
assert result.redacted_request["composition"]["layers"][5]["config"]["credentials"] == "[REDACTED]"
|
||||
redacted_layers = {layer["name"]: layer for layer in result.redacted_request["composition"]["layers"]}
|
||||
assert redacted_layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["credentials"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_normalizes_langgenius_model_provider_for_agent_backend_transport():
|
||||
@@ -262,7 +296,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
|
||||
assert report_schema["oneOf"][3]["required"] == ["transfer_method", "url"]
|
||||
assert output_schema["properties"]["confidence"]["type"] == "number"
|
||||
assert output_schema["required"] == ["report"]
|
||||
assert dumped["composition"]["layers"][5]["config"]["model_settings"] == {"temperature": 0.2}
|
||||
assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_settings"] == {"temperature": 0.2}
|
||||
assert result.metadata["runtime_support"]["reserved_status"]["tools.dify_tools"] == "supported_when_config_valid"
|
||||
assert result.metadata["runtime_support"]["reserved_status"]["tools.cli_tools"] == "supported_by_shell_bootstrap"
|
||||
assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == []
|
||||
@@ -512,12 +546,55 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1"}, {"id": " "}, {"id": "dataset-2"}],
|
||||
"query_config": {
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"score_threshold_enabled": True,
|
||||
},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"description": "Support content",
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
|
||||
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "manual",
|
||||
"conditions": {
|
||||
"logical_operator": "and",
|
||||
"conditions": [
|
||||
{"name": "category", "comparison_operator": "contains", "value": "auth"}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "release",
|
||||
"name": "Release Notes",
|
||||
"datasets": [{"id": "dataset-3"}],
|
||||
"query": {"mode": "user_query", "value": "release notes"},
|
||||
"retrieval": {
|
||||
"mode": "single",
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "automatic",
|
||||
"model_config": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
),
|
||||
@@ -531,25 +608,73 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
|
||||
knowledge_layer = layers["knowledge"]
|
||||
assert knowledge_layer["type"] == "dify.knowledge_base"
|
||||
assert knowledge_layer["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
assert knowledge_layer["config"] == {
|
||||
"dataset_ids": ["dataset-1", "dataset-2"],
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"reranking_mode": "reranking_model",
|
||||
"reranking_enable": True,
|
||||
"reranking_model": None,
|
||||
"weights": None,
|
||||
"model": None,
|
||||
assert knowledge_layer["config"]["sets"] == [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"description": "Support content",
|
||||
"datasets": [
|
||||
{"id": "dataset-1", "name": None, "description": None},
|
||||
{"id": "dataset-2", "name": None, "description": None},
|
||||
],
|
||||
"query": {"mode": "generated_query", "value": None},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"reranking_mode": "reranking_model",
|
||||
"reranking_enable": True,
|
||||
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
|
||||
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
|
||||
"model": None,
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "manual",
|
||||
"metadata_model_config": None,
|
||||
"conditions": {
|
||||
"logical_operator": "and",
|
||||
"conditions": [{"name": "category", "comparison_operator": "contains", "value": "auth"}],
|
||||
},
|
||||
},
|
||||
},
|
||||
"metadata_filtering": {"mode": "disabled", "metadata_model_config": None, "conditions": None},
|
||||
"max_result_content_chars": 2000,
|
||||
"max_observation_chars": 12000,
|
||||
}
|
||||
{
|
||||
"id": "release",
|
||||
"name": "Release Notes",
|
||||
"description": None,
|
||||
"datasets": [{"id": "dataset-3", "name": None, "description": None}],
|
||||
"query": {"mode": "user_query", "value": "release notes"},
|
||||
"retrieval": {
|
||||
"mode": "single",
|
||||
"top_k": None,
|
||||
"score_threshold": 0.0,
|
||||
"reranking_mode": "reranking_model",
|
||||
"reranking_enable": True,
|
||||
"reranking_model": None,
|
||||
"weights": None,
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "automatic",
|
||||
"metadata_model_config": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {},
|
||||
},
|
||||
"conditions": None,
|
||||
},
|
||||
},
|
||||
]
|
||||
assert knowledge_layer["config"]["max_result_content_chars"] == 2000
|
||||
assert knowledge_layer["config"]["max_observation_chars"] == 12000
|
||||
|
||||
|
||||
def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits_it():
|
||||
def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero():
|
||||
context = _context()
|
||||
snapshot = AgentConfigSnapshot(
|
||||
id="snapshot-1",
|
||||
@@ -565,8 +690,19 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query_config": {},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 4,
|
||||
"score_threshold": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
),
|
||||
@@ -577,10 +713,10 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
knowledge_layer = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "knowledge")
|
||||
assert knowledge_layer["config"]["retrieval"]["top_k"] == 4
|
||||
assert knowledge_layer["config"]["sets"][0]["retrieval"]["score_threshold"] == 0.0
|
||||
|
||||
|
||||
def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids():
|
||||
def test_build_skips_knowledge_layer_when_agent_soul_has_no_sets():
|
||||
context = _context()
|
||||
snapshot = AgentConfigSnapshot(
|
||||
id="snapshot-1",
|
||||
@@ -595,9 +731,7 @@ def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids():
|
||||
"model_provider": "openai",
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": " "}, {}],
|
||||
},
|
||||
"knowledge": {"sets": []},
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -793,11 +927,8 @@ def test_effective_declared_outputs_passthrough_when_user_declared():
|
||||
|
||||
|
||||
def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
|
||||
"""ENG-616: slash-menu mention tokens expand to canonical names; node_output
|
||||
mentions expand to the reference name only (the value stays in the Workflow
|
||||
context user prompt), and no ``[§…§]`` marker leaks into the request."""
|
||||
import json
|
||||
|
||||
"""ENG-616: soul/output mentions expand, while frontend workflow markers stay
|
||||
literal in the workflow task layer and resolve under workflow context."""
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = AgentSoulConfig(
|
||||
prompt={"system_prompt": "Careful. Ask [§human:c-1:EMAIL · DAVE§] when unsure."},
|
||||
@@ -807,26 +938,189 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": (
|
||||
"Read [§node_output:previous-node.text:PREV/text§] and produce [§output:summary§]. "
|
||||
"Read {{#previous-node.text#}} and produce [§output:summary§]. "
|
||||
"Unknown [§knowledge:gone:旧手册§] degrades."
|
||||
),
|
||||
"previous_node_output_refs": [
|
||||
{"selector": ["previous-node", "text"], "name": "PREV/text"},
|
||||
],
|
||||
"declared_outputs": [{"name": "summary", "type": "string"}],
|
||||
}
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
assert dumped["composition"]["layers"][0]["config"]["prefix"] == ("Careful. Ask EMAIL · David Hayes when unsure.")
|
||||
assert dumped["composition"]["layers"][1]["config"]["prefix"] == (
|
||||
"Read PREV/text and produce summary (string). Unknown 旧手册 degrades."
|
||||
layers = _request_layers(result)
|
||||
agent_soul_prompt = layers["agent_soul_prompt"]["config"]["prefix"]
|
||||
job_prompt = layers["workflow_node_job_prompt"]["config"]["user"]
|
||||
assert agent_soul_prompt == ("Careful. Ask EMAIL · David Hayes when unsure.")
|
||||
assert job_prompt == "Read {{#previous-node.text#}} and produce summary (string). Unknown 旧手册 degrades."
|
||||
user_prompt = _workflow_user_prompt(result)
|
||||
assert "Agent task for this workflow run:" not in user_prompt
|
||||
assert "Previous result" in user_prompt
|
||||
for prompt_text in (agent_soul_prompt, job_prompt, user_prompt):
|
||||
assert "[§" not in prompt_text
|
||||
assert "{{#" in job_prompt
|
||||
assert "{{#" not in agent_soul_prompt
|
||||
assert "{{#" not in user_prompt
|
||||
|
||||
|
||||
def test_previous_node_file_output_uses_agent_stub_download_mapping_in_workflow_context():
|
||||
file_reference = build_file_reference(record_id="tool-file-1")
|
||||
|
||||
class FileVariablePool(FakeVariablePool):
|
||||
def get(self, selector):
|
||||
if list(selector) == ["previous-node", "report"]:
|
||||
return FileSegment(
|
||||
value=File(
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
reference=file_reference,
|
||||
remote_url=None,
|
||||
filename="report.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=12,
|
||||
)
|
||||
)
|
||||
return super().get(selector)
|
||||
|
||||
context = replace(_context(), variable_pool=FileVariablePool())
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "Review {{#previous-node.report#}} before responding.",
|
||||
}
|
||||
)
|
||||
# the value still rides the Workflow context block, not the job prompt
|
||||
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
|
||||
assert "[§" not in json.dumps(dumped["composition"]["layers"][:3])
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
assert _request_layers(result)["workflow_node_job_prompt"]["config"]["user"] == (
|
||||
"Review {{#previous-node.report#}} before responding."
|
||||
)
|
||||
assert _previous_node_prompt_payload(result, "previous-node.report") == {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": file_reference,
|
||||
}
|
||||
|
||||
|
||||
def test_scalar_previous_node_output_appears_in_workflow_context_section():
|
||||
context = _context()
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "Review {{#previous-node.text#}} before responding.",
|
||||
}
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
user_prompt = _workflow_user_prompt(result)
|
||||
|
||||
assert "- Previous node outputs:" in user_prompt
|
||||
assert " - previous-node.text: Previous result" in user_prompt
|
||||
|
||||
|
||||
def test_stale_previous_node_refs_are_ignored_when_workflow_prompt_has_no_frontend_markers():
|
||||
context = _context()
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "Review the current request without upstream context.",
|
||||
"previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}],
|
||||
}
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
assert _request_layers(result)["workflow_node_job_prompt"]["config"]["user"] == (
|
||||
"Review the current request without upstream context."
|
||||
)
|
||||
assert "Previous node outputs:" not in _workflow_user_prompt(result)
|
||||
|
||||
|
||||
def test_previous_node_file_array_uses_agent_stub_download_mappings_in_workflow_context():
|
||||
first_reference = build_file_reference(record_id="tool-file-1")
|
||||
second_reference = build_file_reference(record_id="tool-file-2")
|
||||
|
||||
class FileArrayVariablePool(FakeVariablePool):
|
||||
def get(self, selector):
|
||||
if list(selector) == ["previous-node", "attachments"]:
|
||||
return ArrayFileSegment(
|
||||
value=[
|
||||
File(
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
reference=first_reference,
|
||||
remote_url=None,
|
||||
filename="first.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=12,
|
||||
),
|
||||
File(
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.REMOTE_URL,
|
||||
reference=None,
|
||||
remote_url="https://example.com/second.pdf",
|
||||
filename="second.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=12,
|
||||
),
|
||||
]
|
||||
)
|
||||
return super().get(selector)
|
||||
|
||||
context = replace(_context(), variable_pool=FileArrayVariablePool())
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "Inspect {{#previous-node.attachments#}}",
|
||||
}
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
assert _previous_node_prompt_payload(result, "previous-node.attachments") == [
|
||||
{
|
||||
"transfer_method": "tool_file",
|
||||
"reference": first_reference,
|
||||
},
|
||||
{
|
||||
"transfer_method": "remote_url",
|
||||
"url": "https://example.com/second.pdf",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_previous_node_remote_url_file_mapping_is_not_truncated_in_workflow_context():
|
||||
remote_url = "https://example.com/" + ("a" * 2100) + ".pdf"
|
||||
|
||||
class LongRemoteUrlVariablePool(FakeVariablePool):
|
||||
def get(self, selector):
|
||||
if list(selector) == ["previous-node", "report"]:
|
||||
return FileSegment(
|
||||
value=File(
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.REMOTE_URL,
|
||||
reference=None,
|
||||
remote_url=remote_url,
|
||||
filename="report.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=12,
|
||||
)
|
||||
)
|
||||
return super().get(selector)
|
||||
|
||||
context = replace(_context(), variable_pool=LongRemoteUrlVariablePool())
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "Use {{#previous-node.report#}}",
|
||||
}
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
assert _previous_node_prompt_payload(result, "previous-node.report") == {
|
||||
"transfer_method": "remote_url",
|
||||
"url": remote_url,
|
||||
}
|
||||
assert "...[truncated]" not in _workflow_user_prompt(result)
|
||||
|
||||
|
||||
# ── ENG-623: dify.drive declaration layer ─────────────────────────────────────
|
||||
@@ -931,10 +1225,9 @@ def test_workflow_run_request_contains_drive_layer_with_empty_catalog(monkeypatc
|
||||
"mentioned_skill_keys": [],
|
||||
"mentioned_file_keys": [],
|
||||
}
|
||||
assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"drive": "drive",
|
||||
}
|
||||
assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] == "agent-agent-1"
|
||||
assert layers["drive"]["deps"] == {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def test_build_drive_layer_config_requires_agent_identity():
|
||||
@@ -960,11 +1253,12 @@ def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
layer_names = [layer["name"] for layer in dumped["composition"]["layers"]]
|
||||
assert "drive" in layer_names
|
||||
# injected right after execution_context, before history/llm
|
||||
assert layer_names.index("drive") == layer_names.index("execution_context") + 1
|
||||
# shell enters first; drive uses that shell to materialize mentioned targets.
|
||||
assert layer_names.index(DIFY_SHELL_LAYER_ID) == layer_names.index("execution_context") + 1
|
||||
assert layer_names.index("drive") == layer_names.index(DIFY_SHELL_LAYER_ID) + 1
|
||||
drive = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "drive")
|
||||
assert drive["type"] == "dify.drive"
|
||||
assert drive["deps"] == {"execution_context": "execution_context"}
|
||||
assert drive["deps"] == {"shell": DIFY_SHELL_LAYER_ID}
|
||||
assert drive["config"]["drive_ref"] == "agent-agent-1"
|
||||
assert drive["config"]["skills"] == [
|
||||
{
|
||||
@@ -1031,7 +1325,10 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
|
||||
assert "[§" not in soul_prompt.config.prefix
|
||||
|
||||
|
||||
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled():
|
||||
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
|
||||
)
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = _soul_with_drive_skill()
|
||||
|
||||
@@ -1094,7 +1391,15 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1", "name": "Product Docs"}],
|
||||
"sets": [
|
||||
{
|
||||
"id": "product",
|
||||
"name": "Product Docs",
|
||||
"datasets": [{"id": "dataset-1", "name": "Product Docs"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1106,13 +1411,13 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu
|
||||
assert all("knowledge" not in w["section"] for w in manifest["unsupported_runtime_warnings"])
|
||||
|
||||
|
||||
def test_feature_manifest_treats_blank_knowledge_dataset_ids_as_not_configured():
|
||||
def test_feature_manifest_treats_empty_knowledge_sets_as_not_configured():
|
||||
from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest
|
||||
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"knowledge": {
|
||||
"datasets": [{"id": " "}, {}],
|
||||
"sets": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -55,6 +55,33 @@ def _snapshot() -> AgentConfigSnapshot:
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_with_knowledge_dataset(dataset_id: str) -> AgentConfigSnapshot:
|
||||
return AgentConfigSnapshot(
|
||||
id="snapshot-1",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
version=1,
|
||||
config_snapshot=AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
knowledge={
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": dataset_id}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _graph(edges: list[dict]) -> dict:
|
||||
return {
|
||||
"nodes": [
|
||||
@@ -515,6 +542,35 @@ def test_publish_validation_rejects_missing_file_ref():
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_missing_or_out_of_scope_knowledge_datasets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
dataset_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
node_job = WorkflowNodeJobConfig.model_validate({})
|
||||
snapshot = _snapshot_with_knowledge_dataset(dataset_id)
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_get_datasets_by_ids(ids, tenant_id):
|
||||
captured["ids"] = ids
|
||||
captured["tenant_id"] = tenant_id
|
||||
return [], 0
|
||||
|
||||
import services.dataset_service as dataset_service_module
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match=dataset_id):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
|
||||
)
|
||||
|
||||
assert captured == {"ids": [dataset_id], "tenant_id": "tenant-1"}
|
||||
|
||||
|
||||
def test_publish_validation_accepts_tool_node_agentic_manual_mode():
|
||||
session = Mock()
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ def _run_migration_step(module: object, engine: sa.Engine, step_name: str) -> No
|
||||
module.op = original_op
|
||||
|
||||
|
||||
def test_upgrade_adds_skill_columns_and_index_and_strips_snapshot_data() -> None:
|
||||
def test_upgrade_adds_skill_columns_and_index_and_preserves_snapshot_data() -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
_create_pre_upgrade_schema(engine)
|
||||
snapshot = {
|
||||
@@ -91,7 +91,7 @@ def test_upgrade_adds_skill_columns_and_index_and_strips_snapshot_data() -> None
|
||||
sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"),
|
||||
{"id": "snap-1"},
|
||||
).scalar_one()
|
||||
assert "skills_files" not in json.loads(stored_snapshot)
|
||||
assert json.loads(stored_snapshot) == snapshot
|
||||
|
||||
|
||||
def test_downgrade_drops_skill_columns_and_index_without_reconstructing_legacy_data() -> None:
|
||||
|
||||
@@ -7,6 +7,8 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -34,6 +36,9 @@ def test_agent_enums_match_prd_boundaries():
|
||||
assert AgentStatus.ARCHIVED.value == "archived"
|
||||
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version"
|
||||
assert AgentConfigRevisionOperation.RESTORE_VERSION.value == "restore_version"
|
||||
assert AgentConfigRevisionOperation.PUBLISH_DRAFT.value == "publish_draft"
|
||||
assert AgentConfigDraftType.DRAFT.value == "draft"
|
||||
assert AgentConfigDraftType.DEBUG_BUILD.value == "debug_build"
|
||||
assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent"
|
||||
assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent"
|
||||
|
||||
@@ -136,6 +141,23 @@ def test_current_snapshot_stores_agent_soul_snapshot_as_long_text_json():
|
||||
assert version.config_snapshot_dict["env"]["secret_refs"][0]["provider_credential_id"] == "cred-1"
|
||||
|
||||
|
||||
def test_agent_config_draft_stores_editable_agent_soul_as_long_text_json():
|
||||
config_snapshot = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "draft prompt"}})
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
draft_owner_key="",
|
||||
config_snapshot=config_snapshot,
|
||||
)
|
||||
|
||||
config_snapshot_column = AgentConfigDraft.__table__.c.config_snapshot
|
||||
assert isinstance(config_snapshot_column.type, JSONModelColumn)
|
||||
assert config_snapshot_column.server_default is None
|
||||
assert draft.config_snapshot_dict == config_snapshot.model_dump(mode="json")
|
||||
assert draft.config_snapshot_dict["prompt"]["system_prompt"] == "draft prompt"
|
||||
|
||||
|
||||
def test_workflow_binding_stores_node_job_config_separately_from_agent_soul():
|
||||
node_job_config = {
|
||||
"schema_version": 1,
|
||||
@@ -166,6 +188,7 @@ def test_long_text_columns_do_not_use_mysql_incompatible_server_defaults():
|
||||
assert isinstance(column.type, LongText)
|
||||
assert column.server_default is None
|
||||
assert AgentConfigSnapshot.__table__.c.config_snapshot.server_default is None
|
||||
assert AgentConfigDraft.__table__.c.config_snapshot.server_default is None
|
||||
assert WorkflowAgentNodeBinding.__table__.c.node_job_config.server_default is None
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from models.agent_config_entities import AgentKnowledgeQueryMode, AgentSoulModelConfig, DeclaredOutputType
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
@@ -26,6 +27,24 @@ def test_workflow_variant_rejects_agent_app_only_fields():
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_variant_accepts_agent_soul_files_section():
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.WORKFLOW,
|
||||
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY,
|
||||
"agent_soul": {
|
||||
"schema_version": 1,
|
||||
"prompt": {"system_prompt": "jjjj"},
|
||||
"files": {"skills": [], "files": []},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert payload.agent_soul is not None
|
||||
assert payload.agent_soul.files.skills == []
|
||||
assert payload.agent_soul.files.files == []
|
||||
|
||||
|
||||
def test_agent_app_variant_rejects_workflow_node_job():
|
||||
with pytest.raises(ValueError):
|
||||
ComposerSavePayload.model_validate(
|
||||
@@ -131,14 +150,144 @@ def test_knowledge_query_mode_uses_stable_backend_enums():
|
||||
config = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"knowledge": {
|
||||
"datasets": [{"dataset_id": "dataset-1"}],
|
||||
"query_mode": "generated_query",
|
||||
"query_config": {"generation_prompt": "Create a retrieval query."},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert config.knowledge.query_mode == AgentKnowledgeQueryMode.GENERATED_QUERY
|
||||
assert config.knowledge.sets[0].query.mode == AgentKnowledgeQueryMode.GENERATED_QUERY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("knowledge_payload", "match"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Billing KB",
|
||||
"datasets": [{"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
]
|
||||
},
|
||||
"knowledge set ids must be unique",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Shared KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
{
|
||||
"id": "billing",
|
||||
"name": "Shared KB",
|
||||
"datasets": [{"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
]
|
||||
},
|
||||
"knowledge set names must be unique",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}, {"id": " dataset-1 "}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
]
|
||||
},
|
||||
"knowledge set dataset ids must be unique",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "user_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
]
|
||||
},
|
||||
"knowledge query.value is required for user_query mode",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "single"},
|
||||
},
|
||||
]
|
||||
},
|
||||
"knowledge retrieval.model is required for single mode",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"metadata_filtering": {"mode": "automatic"},
|
||||
},
|
||||
]
|
||||
},
|
||||
"metadata_filtering.model_config is required for automatic mode",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"metadata_filtering": {"mode": "manual"},
|
||||
},
|
||||
]
|
||||
},
|
||||
"metadata_filtering.conditions is required for manual mode",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str):
|
||||
with pytest.raises(ValidationError, match=match):
|
||||
AgentSoulConfig.model_validate({"knowledge": knowledge_payload})
|
||||
|
||||
|
||||
def test_agent_soul_model_config_is_first_class_without_credentials():
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user