Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc68e02711 |
@@ -25,8 +25,6 @@ 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,22 +78,11 @@ def _filter_snapshot_to_specs(
|
||||
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
|
||||
|
||||
|
||||
def _shell_layer_deps() -> dict[str, str]:
|
||||
return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
|
||||
|
||||
def _drive_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _shell_config_with_drive_ref(
|
||||
shell_config: DifyShellLayerConfig | None,
|
||||
drive_config: DifyDriveLayerConfig | None,
|
||||
) -> DifyShellLayerConfig:
|
||||
config = shell_config or DifyShellLayerConfig()
|
||||
if drive_config is None:
|
||||
return config
|
||||
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
|
||||
def _shell_layer_deps(*, include_drive: bool) -> dict[str, str]:
|
||||
deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
if include_drive:
|
||||
deps["drive"] = DIFY_DRIVE_LAYER_ID
|
||||
return deps
|
||||
|
||||
|
||||
class AgentBackendModelConfig(BaseModel):
|
||||
@@ -274,29 +263,14 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = run_input.include_shell or run_input.drive_config is not None
|
||||
if include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
# Drive Skills & Files declaration (dify.drive): a config-only index;
|
||||
# the agent pulls listed entries through the back proxy by drive_ref.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps=_drive_layer_deps(),
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
@@ -338,7 +312,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
@@ -362,6 +336,21 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context
|
||||
# so the agent server can mint per-command Agent Stub env, and on
|
||||
# drive when present so that env points at /mnt/drive/<drive_ref>.
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.output is not None:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -456,7 +445,7 @@ class AgentBackendRunRequestBuilder:
|
||||
name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "workflow_node_job"},
|
||||
config=PromptLayerConfig(user=run_input.workflow_node_job_prompt),
|
||||
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
@@ -473,29 +462,14 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = run_input.include_shell or run_input.drive_config is not None
|
||||
if include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
# Drive Skills & Files declaration (dify.drive): a config-only index;
|
||||
# the agent pulls listed entries through the back proxy by drive_ref.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps=_drive_layer_deps(),
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
@@ -539,7 +513,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
@@ -563,6 +537,21 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context
|
||||
# so the agent server can mint per-command Agent Stub env, and on
|
||||
# drive when present so that env points at /mnt/drive/<drive_ref>.
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.output is not None:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
|
||||
@@ -35,12 +35,6 @@ class WorkflowRunArchiveTenantPlan(TypedDict):
|
||||
unpaid_tenant_ids: list[str]
|
||||
|
||||
|
||||
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=datetime.UTC)
|
||||
return value.astimezone(datetime.UTC)
|
||||
|
||||
|
||||
def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
|
||||
if not prefixes:
|
||||
return []
|
||||
@@ -162,16 +156,11 @@ def _resolve_archive_time_range(
|
||||
raise click.UsageError("Choose either day offsets or explicit dates, not both.")
|
||||
if from_days_ago <= to_days_ago:
|
||||
raise click.UsageError("--from-days-ago must be greater than --to-days-ago.")
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now = datetime.datetime.now()
|
||||
start_from = now - datetime.timedelta(days=from_days_ago)
|
||||
end_before = now - datetime.timedelta(days=to_days_ago)
|
||||
before_days = 0
|
||||
|
||||
if start_from is not None:
|
||||
start_from = _normalize_utc_datetime(start_from)
|
||||
if end_before is not None:
|
||||
end_before = _normalize_utc_datetime(end_before)
|
||||
|
||||
if start_from and end_before and start_from >= end_before:
|
||||
raise click.UsageError("--start-from must be earlier than --end-before.")
|
||||
|
||||
@@ -413,13 +402,6 @@ def archive_workflow_runs_plan(
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
click.echo(
|
||||
click.style(
|
||||
"fixed_archive_window="
|
||||
f"{start_from.isoformat() if start_from else 'unbounded'},{plan_end_before.isoformat()}",
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
click.echo("tenant_prefix,total_tenants,workflow_runs,workflow_node_executions,paid_tenants,unpaid_tenants")
|
||||
for row in rows:
|
||||
click.echo(
|
||||
@@ -469,7 +451,7 @@ def archive_workflow_runs_plan(
|
||||
default=None,
|
||||
help="Archive runs created before this timestamp (UTC if no timezone).",
|
||||
)
|
||||
@click.option("--batch-size", default=10000, show_default=True, help="Maximum workflow runs per archive bundle.")
|
||||
@click.option("--batch-size", default=100, show_default=True, help="Maximum workflow runs per archive bundle.")
|
||||
@click.option(
|
||||
"--workers",
|
||||
default=1,
|
||||
@@ -539,7 +521,6 @@ def archive_workflow_runs(
|
||||
)
|
||||
)
|
||||
|
||||
uses_relative_window = start_from is None and end_before is None
|
||||
try:
|
||||
before_days, start_from, end_before = _resolve_archive_time_range(
|
||||
before_days=before_days,
|
||||
@@ -565,14 +546,6 @@ def archive_workflow_runs(
|
||||
if delete_after_archive:
|
||||
click.echo(click.style("delete-after-archive is not supported by bundle archive.", fg="red"))
|
||||
return
|
||||
if uses_relative_window:
|
||||
click.echo(
|
||||
click.style(
|
||||
"Relative archive windows are evaluated at command start. For multi-day prefix/shard rollout, "
|
||||
"reuse absolute --start-from/--end-before values from archive-workflow-runs-plan.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
tenant_plan = _resolve_archive_tenant_ids_from_plan(
|
||||
|
||||
@@ -36,8 +36,8 @@ class AgentBackendConfig(BaseSettings):
|
||||
description=(
|
||||
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
|
||||
"into Agent runs. The declaration is an index only — the agent backend "
|
||||
"pulls the actual SKILL.md / files through the back proxy. Set this to "
|
||||
"false only when temporarily rolling back the drive integration."
|
||||
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
|
||||
"until the agent backend registers the dify.drive layer type."
|
||||
),
|
||||
default=True,
|
||||
default=False,
|
||||
)
|
||||
|
||||
@@ -183,7 +183,6 @@ class Site(BaseModel):
|
||||
description: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
default_language: str
|
||||
show_workflow_steps: bool
|
||||
|
||||
@@ -6,15 +6,5 @@ from services.agent.roster_service import AgentRosterService
|
||||
|
||||
|
||||
def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App:
|
||||
"""Resolve a roster Agent's public Agent App."""
|
||||
"""Resolve the hidden Agent App backing an Agent Console resource."""
|
||||
return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
|
||||
|
||||
def resolve_agent_runtime_app_model(*, tenant_id: str, agent_id: UUID) -> App:
|
||||
"""Resolve the App that backs an Agent runtime surface.
|
||||
|
||||
This accepts both roster Agent Apps and workflow-only inline Agents with a
|
||||
hidden backing App.
|
||||
"""
|
||||
|
||||
return AgentRosterService(db.session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -28,15 +28,9 @@ from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.entities.agent_entities import (
|
||||
ComposerSavePayload,
|
||||
WorkflowAgentComposerQuery,
|
||||
WorkflowComposerCopyFromRosterPayload,
|
||||
)
|
||||
from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload
|
||||
|
||||
register_schema_models(
|
||||
console_ns, ComposerSavePayload, WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload
|
||||
)
|
||||
register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentAppComposerResponse,
|
||||
@@ -47,26 +41,27 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str:
|
||||
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
|
||||
class WorkflowAgentComposerApi(Resource):
|
||||
@console_ns.response(
|
||||
200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]
|
||||
)
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowAgentComposerQuery))
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, app_model: App, node_id: str):
|
||||
query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True))
|
||||
return dump_response(
|
||||
WorkflowAgentComposerResponse,
|
||||
AgentComposerService.load_workflow_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
snapshot_id=query.snapshot_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -142,7 +137,6 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
def post(self, tenant_id: str, app_model: App, node_id: str):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
@@ -234,9 +228,10 @@ class AgentComposerApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return dump_response(
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)),
|
||||
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id),
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@@ -249,12 +244,13 @@ class AgentComposerApi(Resource):
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, account_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return dump_response(
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerService.save_agent_composer(
|
||||
AgentComposerService.save_agent_app_composer(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
app_id=app_id,
|
||||
account_id=account_id,
|
||||
payload=payload,
|
||||
),
|
||||
@@ -272,10 +268,9 @@ class AgentComposerValidateApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID):
|
||||
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
@@ -295,11 +290,12 @@ class AgentComposerCandidatesApi(Resource):
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return dump_response(
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerService.get_agent_app_candidates(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
app_id=app_id,
|
||||
user_id=current_user_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy import func, select
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model, resolve_agent_runtime_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
|
||||
from controllers.console.app.app import (
|
||||
AppDetailWithSite as GenericAppDetailWithSite,
|
||||
@@ -54,10 +54,8 @@ from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.agent import Agent, AgentStatus
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, IconType
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.errors import AgentNotFoundError
|
||||
from services.agent.observability_service import (
|
||||
AgentLogQueryParams,
|
||||
@@ -67,7 +65,7 @@ from services.agent.observability_service import (
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
|
||||
from services.entities.agent_entities import RosterListQuery
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
@@ -234,8 +232,6 @@ class AgentStatisticsQuery(BaseModel):
|
||||
|
||||
class AgentAppPartial(GenericAppPartial):
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
debug_conversation_id: str | None = None
|
||||
role: str | None = None
|
||||
active_config_is_published: bool = False
|
||||
@@ -245,8 +241,6 @@ class AgentAppPartial(GenericAppPartial):
|
||||
|
||||
class AgentAppDetailWithSite(GenericAppDetailWithSite):
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
debug_conversation_id: str | None = None
|
||||
role: str | None = None
|
||||
active_config_is_published: bool = False
|
||||
@@ -256,36 +250,6 @@ class AgentDebugConversationRefreshResponse(BaseModel):
|
||||
debug_conversation_id: str
|
||||
|
||||
|
||||
class AgentPublishPayload(BaseModel):
|
||||
version_note: str | None = Field(default=None, description="Optional note for this published Agent version")
|
||||
|
||||
|
||||
class AgentPublishResponse(BaseModel):
|
||||
result: str
|
||||
active_config_snapshot_id: str
|
||||
active_config_snapshot: dict[str, object] | None = None
|
||||
draft: dict[str, object] | None = None
|
||||
|
||||
|
||||
class AgentBuildDraftCheckoutPayload(BaseModel):
|
||||
force: bool = Field(default=False, description="Overwrite the existing current-user build draft")
|
||||
|
||||
|
||||
class AgentBuildDraftResponse(BaseModel):
|
||||
variant: str
|
||||
draft: dict[str, object]
|
||||
agent_soul: dict[str, object]
|
||||
|
||||
|
||||
class AgentBuildDraftApplyResponse(BaseModel):
|
||||
result: str
|
||||
draft: dict[str, object]
|
||||
|
||||
|
||||
class AgentSimpleResultResponse(BaseModel):
|
||||
result: str
|
||||
|
||||
|
||||
class AgentAppPagination(GenericAppPagination):
|
||||
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
|
||||
validation_alias=AliasChoices("items", "data")
|
||||
@@ -297,9 +261,6 @@ register_schema_models(
|
||||
AgentAppCreatePayload,
|
||||
AgentAppUpdatePayload,
|
||||
AgentAppCopyPayload,
|
||||
AgentPublishPayload,
|
||||
AgentBuildDraftCheckoutPayload,
|
||||
ComposerSavePayload,
|
||||
AgentApiStatusPayload,
|
||||
AgentInviteOptionsQuery,
|
||||
AgentLogsQuery,
|
||||
@@ -316,10 +277,6 @@ register_response_schema_models(
|
||||
AgentAppDetailWithSite,
|
||||
AgentAppPartial,
|
||||
AgentDebugConversationRefreshResponse,
|
||||
AgentPublishResponse,
|
||||
AgentBuildDraftResponse,
|
||||
AgentBuildDraftApplyResponse,
|
||||
AgentSimpleResultResponse,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentConfigSnapshotRestoreResponse,
|
||||
@@ -337,7 +294,7 @@ def _agent_roster_service() -> AgentRosterService:
|
||||
return AgentRosterService(db.session)
|
||||
|
||||
|
||||
def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: str | None = None) -> dict:
|
||||
def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
|
||||
"""Serialize an Agent App detail using roster-only DTOs.
|
||||
|
||||
`/agent` responses are roster-shaped rather than raw app-shaped: `id`
|
||||
@@ -354,23 +311,11 @@ def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: s
|
||||
|
||||
roster_service = _agent_roster_service()
|
||||
payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
|
||||
agent = (
|
||||
db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
if agent_id
|
||||
else roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
|
||||
)
|
||||
agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
|
||||
if not agent:
|
||||
raise AgentNotFoundError()
|
||||
payload.pop("bound_agent_id", None)
|
||||
payload["app_id"] = agent.app_id
|
||||
payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent)
|
||||
payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id)
|
||||
payload["app_id"] = str(app_model.id)
|
||||
payload["id"] = agent.id
|
||||
payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
@@ -420,8 +365,6 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_u
|
||||
agent = agents_by_app_id.get(app_id)
|
||||
if agent:
|
||||
item["app_id"] = app_id
|
||||
item["backing_app_id"] = agent.backing_app_id or app_id
|
||||
item["hidden_app_backed"] = False
|
||||
item["id"] = agent.id
|
||||
item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id)
|
||||
item["role"] = agent.role or ""
|
||||
@@ -573,8 +516,8 @@ class AgentAppApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _serialize_agent_app_detail(app_model, current_user=current_user, agent_id=str(agent_id))
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _serialize_agent_app_detail(app_model, current_user=current_user)
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@@ -640,112 +583,6 @@ class AgentDebugConversationRefreshApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/publish")
|
||||
class AgentPublishApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentPublishPayload.__name__])
|
||||
@console_ns.response(200, "Agent draft published", console_ns.models[AgentPublishResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentPublishPayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.publish_agent_app_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
version_note=args.version_note,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft/checkout")
|
||||
class AgentBuildDraftCheckoutApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentBuildDraftCheckoutPayload.__name__])
|
||||
@console_ns.response(200, "Agent build draft checked out", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.checkout_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
force=args.force,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft")
|
||||
class AgentBuildDraftApi(Resource):
|
||||
@console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.load_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(200, "Agent build draft saved", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.save_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
@console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.discard_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft/apply")
|
||||
class AgentBuildDraftApplyApi(Resource):
|
||||
@console_ns.response(200, "Agent build draft applied", console_ns.models[AgentBuildDraftApplyResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.apply_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/copy")
|
||||
class AgentAppCopyApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__])
|
||||
@@ -875,7 +712,7 @@ class AgentLogsApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
|
||||
query_data["sources"] = _multi_query_values("sources", "source")
|
||||
query_data["statuses"] = _multi_query_values("statuses", "status")
|
||||
@@ -912,7 +749,7 @@ class AgentLogMessagesApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
|
||||
query_data["sources"] = _multi_query_values("sources", "source")
|
||||
query_data["statuses"] = _multi_query_values("statuses", "status")
|
||||
@@ -949,7 +786,7 @@ class AgentLogSourcesApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id))
|
||||
return dump_response(AgentLogSourceListResponse, payload)
|
||||
|
||||
@@ -968,7 +805,7 @@ class AgentStatisticsSummaryApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
timezone = current_user.timezone or "UTC"
|
||||
start, end = _parse_observability_time_range(query.start, query.end, current_user)
|
||||
|
||||
@@ -13,7 +13,7 @@ from controllers.common.schema import (
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -351,7 +351,7 @@ class AgentSkillUploadByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@@ -394,7 +394,7 @@ class AgentDriveFilesByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file_by_agent")
|
||||
@@ -407,7 +407,7 @@ class AgentDriveFilesByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
|
||||
|
||||
|
||||
@@ -454,7 +454,7 @@ class AgentSkillByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False)
|
||||
|
||||
|
||||
@@ -494,7 +494,7 @@ class AgentSkillInferToolsByAgentApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
@@ -87,7 +87,7 @@ class AgentAppFeatureConfigResource(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
new_app_model_config = AgentAppFeatureConfigService.update_features(
|
||||
|
||||
@@ -22,7 +22,7 @@ from controllers.common.schema import (
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from fields.base import ResponseModel
|
||||
@@ -144,7 +144,7 @@ class AgentAppSandboxListResource(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxListQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().list_files(
|
||||
@@ -169,7 +169,7 @@ class AgentAppSandboxReadResource(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxFileQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().read_file(
|
||||
@@ -194,7 +194,7 @@ class AgentAppSandboxUploadResource(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
|
||||
try:
|
||||
result = AgentAppSandboxService().upload_file(
|
||||
|
||||
@@ -25,7 +25,7 @@ from controllers.common.schema import (
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from fields.base import ResponseModel
|
||||
@@ -182,7 +182,7 @@ class AgentDriveListByAgentApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveListByAgentQuery)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
|
||||
except AgentDriveError as exc:
|
||||
@@ -201,7 +201,7 @@ class AgentDriveSkillListByAgentApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
except AgentDriveError as exc:
|
||||
@@ -220,7 +220,7 @@ class AgentDriveSkillInspectByAgentApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID, skill_path: str):
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
@@ -245,7 +245,7 @@ class AgentDrivePreviewByAgentApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
@@ -264,7 +264,7 @@ class AgentDriveDownloadByAgentApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
|
||||
@@ -331,7 +331,7 @@ class ModelConfig(ResponseModel):
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class AppDetailSiteResponse(ResponseModel):
|
||||
class Site(ResponseModel):
|
||||
access_token: str | None = Field(default=None, validation_alias="code")
|
||||
code: str | None = None
|
||||
title: str | None = None
|
||||
@@ -345,7 +345,6 @@ class AppDetailSiteResponse(ResponseModel):
|
||||
customize_domain: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
customize_token_strategy: str | None = None
|
||||
prompt_public: bool | None = None
|
||||
@@ -462,7 +461,7 @@ class AppDetailWithSite(AppDetail):
|
||||
api_base_url: str | None = None
|
||||
max_active_requests: int | None = None
|
||||
deleted_tools: list[DeletedTool] = Field(default_factory=list)
|
||||
site: AppDetailSiteResponse | None = None
|
||||
site: Site | None = None
|
||||
# For Agent App type: the roster Agent backing this app (None otherwise).
|
||||
bound_agent_id: str | None = None
|
||||
# For Agent App responses exposed through /agent.
|
||||
@@ -547,7 +546,7 @@ register_schema_models(
|
||||
WorkflowPartial,
|
||||
ModelConfigPartial,
|
||||
ModelConfig,
|
||||
AppDetailSiteResponse,
|
||||
Site,
|
||||
DeletedTool,
|
||||
AppDetail,
|
||||
AppExportResponse,
|
||||
|
||||
@@ -11,7 +11,7 @@ import services
|
||||
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
@@ -93,10 +93,6 @@ class ChatMessagePayload(BaseMessagePayload):
|
||||
query: str = Field(..., description="User query")
|
||||
conversation_id: str | None = Field(default=None, description="Conversation ID")
|
||||
parent_message_id: str | None = Field(default=None, description="Parent message ID")
|
||||
draft_type: Literal["draft", "debug_build"] = Field(
|
||||
default="draft",
|
||||
description="Agent App debug config source. Use debug_build while the Agent is in build mode.",
|
||||
)
|
||||
|
||||
@field_validator("conversation_id", "parent_message_id")
|
||||
@classmethod
|
||||
@@ -222,7 +218,7 @@ class AgentChatMessageApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _create_chat_message(
|
||||
current_tenant_id=current_tenant_id,
|
||||
current_user=current_user,
|
||||
@@ -258,7 +254,7 @@ class AgentChatMessageStopApi(Resource):
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes
|
||||
from controllers.common.fields import SimpleResultResponse, TextFileResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.error import (
|
||||
CompletionRequestError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
@@ -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_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _list_chat_messages(app_model=app_model, current_user=current_user)
|
||||
|
||||
|
||||
@@ -250,7 +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_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _update_message_feedback(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@@ -315,7 +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_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
|
||||
|
||||
|
||||
@@ -393,7 +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_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _get_message_detail(app_model=app_model, message_id=message_id)
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ class AppSiteUpdatePayload(BaseModel):
|
||||
customize_domain: str | None = Field(default=None)
|
||||
copyright: str | None = Field(default=None)
|
||||
privacy_policy: str | None = Field(default=None)
|
||||
input_placeholder: str | None = Field(default=None)
|
||||
custom_disclaimer: str | None = Field(default=None)
|
||||
customize_token_strategy: Literal["must", "allow", "not_allow"] | None = Field(default=None)
|
||||
prompt_public: bool | None = Field(default=None)
|
||||
@@ -67,7 +66,6 @@ class AppSiteResponse(ResponseModel):
|
||||
customize_domain: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
customize_token_strategy: str
|
||||
prompt_public: bool
|
||||
@@ -112,7 +110,6 @@ class AppSite(Resource):
|
||||
"customize_domain",
|
||||
"copyright",
|
||||
"privacy_policy",
|
||||
"input_placeholder",
|
||||
"custom_disclaimer",
|
||||
"customize_token_strategy",
|
||||
"prompt_public",
|
||||
|
||||
@@ -16,7 +16,6 @@ from controllers.console.wraps import (
|
||||
with_current_user,
|
||||
)
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
@@ -51,7 +50,7 @@ class Subscription(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True))
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id)
|
||||
|
||||
|
||||
@@ -65,7 +64,7 @@ class Invoices(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
return BillingService.get_invoices(current_user.email, current_tenant_id)
|
||||
|
||||
|
||||
|
||||
@@ -602,7 +602,7 @@ class DatasetApi(Resource):
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
@@ -774,7 +774,7 @@ class DatasetQueryApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -915,7 +915,7 @@ class DatasetRelatedAppListApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -1194,7 +1194,7 @@ class DatasetPermissionUserListApi(Resource):
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ class DocumentResource(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -206,7 +206,7 @@ class DocumentResource(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -247,7 +247,7 @@ class GetProcessRuleApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -322,7 +322,7 @@ class DatasetDocumentListApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -431,7 +431,7 @@ class DatasetDocumentListApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -1173,7 +1173,7 @@ class DocumentStatusApi(DocumentResource):
|
||||
DatasetService.check_dataset_model_setting(dataset)
|
||||
|
||||
# check user's permission
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
document_ids = request.args.getlist("document_id")
|
||||
|
||||
@@ -1440,7 +1440,7 @@ class DocumentGenerateSummaryApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -1537,7 +1537,7 @@ class DocumentSummaryStatusApi(DocumentResource):
|
||||
|
||||
# Check permissions
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ class DatasetDocumentSegmentListApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -302,7 +302,7 @@ class DatasetDocumentSegmentListApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
SegmentService.delete_segments(segment_ids, document, dataset)
|
||||
@@ -345,7 +345,7 @@ class DatasetDocumentSegmentApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
||||
@@ -421,7 +421,7 @@ class DatasetDocumentSegmentAddApi(Resource):
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -494,7 +494,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -550,7 +550,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
SegmentService.delete_segment(segment, document, dataset)
|
||||
@@ -687,7 +687,7 @@ class ChildChunkAddApi(Resource):
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -789,7 +789,7 @@ class ChildChunkAddApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -862,7 +862,7 @@ class ChildChunkUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
try:
|
||||
@@ -930,7 +930,7 @@ class ChildChunkUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
|
||||
@@ -382,7 +382,7 @@ class ExternalKnowledgeHitTestingApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ class DatasetsHitTestingBase:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ class DatasetMetadataCreateApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
metadata = MetadataService.create_metadata(
|
||||
db.session(), dataset_id_str, metadata_args, current_user, current_tenant_id
|
||||
@@ -108,7 +108,7 @@ class DatasetMetadataApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
metadata = MetadataService.update_metadata_name(
|
||||
db.session(), dataset_id_str, metadata_id_str, name, current_user, current_tenant_id
|
||||
@@ -128,7 +128,7 @@ class DatasetMetadataApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
|
||||
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
|
||||
@@ -165,7 +165,7 @@ class DatasetMetadataBuiltInFieldActionApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
@@ -194,7 +194,7 @@ class DocumentMetadataEditApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
metadata_args = MetadataOperationData.model_validate(console_ns.payload or {})
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
@@ -353,7 +352,7 @@ class ModelProviderPaymentCheckoutUrlApi(Resource):
|
||||
def get(self, current_tenant_id: str, current_user: Account, provider: str):
|
||||
if provider != "anthropic":
|
||||
raise ValueError(f"provider name {provider} is invalid")
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
data = BillingService.get_model_provider_payment_link(
|
||||
provider_name=provider,
|
||||
tenant_id=current_tenant_id,
|
||||
|
||||
@@ -14,12 +14,11 @@ api = ExternalApi(
|
||||
|
||||
files_ns = Namespace("files", description="File operations", path="/")
|
||||
|
||||
from . import agent_drive_archive, image_preview, tool_files, upload
|
||||
from . import image_preview, tool_files, upload
|
||||
|
||||
api.add_namespace(files_ns)
|
||||
|
||||
__all__ = [
|
||||
"agent_drive_archive",
|
||||
"api",
|
||||
"bp",
|
||||
"files_ns",
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.common.file_response import enforce_download_for_html
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.files import files_ns
|
||||
from models.agent import AgentDriveFileKind
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
class AgentDriveArchiveMemberQuery(BaseModel):
|
||||
tenant_id: str = Field(..., description="Tenant ID")
|
||||
agent_id: str = Field(..., description="Agent ID")
|
||||
key: str = Field(..., description="Virtual drive key")
|
||||
archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind")
|
||||
archive_file_id: str = Field(..., description="Archive file id")
|
||||
member_path: str = Field(..., description="Zip member path")
|
||||
timestamp: str = Field(..., description="Unix timestamp")
|
||||
nonce: str = Field(..., description="Random nonce")
|
||||
sign: str = Field(..., description="HMAC signature")
|
||||
as_attachment: bool = Field(default=False, description="Download as attachment")
|
||||
|
||||
|
||||
register_schema_models(files_ns, AgentDriveArchiveMemberQuery)
|
||||
|
||||
|
||||
@files_ns.route("/agent-drive/archive-member")
|
||||
class AgentDriveArchiveMemberApi(Resource):
|
||||
@files_ns.doc("get_agent_drive_archive_member")
|
||||
@files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters")
|
||||
def get(self):
|
||||
args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True))
|
||||
if not AgentDriveService.verify_archive_member_signature(
|
||||
tenant_id=args.tenant_id,
|
||||
agent_id=args.agent_id,
|
||||
key=args.key,
|
||||
archive_file_kind=args.archive_file_kind,
|
||||
archive_file_id=args.archive_file_id,
|
||||
member_path=args.member_path,
|
||||
timestamp=args.timestamp,
|
||||
nonce=args.nonce,
|
||||
sign=args.sign,
|
||||
):
|
||||
raise Forbidden("Invalid request.")
|
||||
try:
|
||||
payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request(
|
||||
tenant_id=args.tenant_id,
|
||||
agent_id=args.agent_id,
|
||||
key=args.key,
|
||||
archive_file_kind=args.archive_file_kind,
|
||||
archive_file_id=args.archive_file_id,
|
||||
member_path=args.member_path,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
raise NotFound(exc.message) from exc
|
||||
|
||||
response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
|
||||
response.headers["Content-Length"] = str(len(payload))
|
||||
if args.as_attachment and filename:
|
||||
encoded_filename = quote(filename)
|
||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
|
||||
return response
|
||||
@@ -34,6 +34,7 @@ class OpenApiErrorCode(StrEnum):
|
||||
# transport-generic (resolved from HTTP status for plain werkzeug raises)
|
||||
BAD_REQUEST = "bad_request"
|
||||
UNAUTHORIZED = "unauthorized"
|
||||
TOKEN_EXPIRED = "token_expired"
|
||||
FORBIDDEN = "forbidden"
|
||||
NOT_FOUND = "not_found"
|
||||
METHOD_NOT_ALLOWED = "method_not_allowed"
|
||||
@@ -223,6 +224,19 @@ class OpenApiErrorFormatter:
|
||||
return isinstance(part, (str, int)) and not isinstance(part, bool)
|
||||
|
||||
|
||||
class InvalidBearer(OpenApiError): # noqa: N818
|
||||
code = 401
|
||||
error_code = OpenApiErrorCode.UNAUTHORIZED
|
||||
description = "Invalid or unknown bearer token."
|
||||
|
||||
|
||||
class SessionExpired(OpenApiError): # noqa: N818
|
||||
code = 401
|
||||
error_code = OpenApiErrorCode.TOKEN_EXPIRED
|
||||
description = "Your session has expired."
|
||||
hint = "Re-authenticate to continue (e.g. re-run your login command)."
|
||||
|
||||
|
||||
class FilenameNotExists(OpenApiError): # noqa: N818
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.FILENAME_NOT_EXISTS
|
||||
|
||||
@@ -17,6 +17,7 @@ from flask_login import user_logged_in
|
||||
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
|
||||
|
||||
from controllers.openapi._audit import emit_wrong_surface
|
||||
from controllers.openapi._errors import InvalidBearer, SessionExpired
|
||||
from controllers.openapi.auth.data import (
|
||||
AuthData,
|
||||
Edition,
|
||||
@@ -28,7 +29,9 @@ from controllers.openapi.auth.data import (
|
||||
from controllers.openapi.auth.flow import When
|
||||
from libs.oauth_bearer import (
|
||||
AuthContext,
|
||||
InvalidBearerError,
|
||||
Scope,
|
||||
TokenExpiredError,
|
||||
TokenType,
|
||||
extract_bearer,
|
||||
get_authenticator,
|
||||
@@ -217,7 +220,12 @@ class PipelineRouter:
|
||||
if not token:
|
||||
raise Unauthorized("bearer required")
|
||||
|
||||
identity = get_authenticator().authenticate(token)
|
||||
try:
|
||||
identity = get_authenticator().authenticate(token)
|
||||
except TokenExpiredError:
|
||||
raise SessionExpired()
|
||||
except InvalidBearerError:
|
||||
raise InvalidBearer()
|
||||
|
||||
if allowed_token_types is not None and identity.token_type not in allowed_token_types:
|
||||
emit_wrong_surface(
|
||||
|
||||
@@ -565,7 +565,7 @@ class DatasetApi(DatasetApiResource):
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
data = _dump_service_dataset_detail(dataset)
|
||||
@@ -819,7 +819,7 @@ class DocumentStatusApi(DatasetApiResource):
|
||||
|
||||
# Check user's permission
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
metadata = MetadataService.create_metadata(db.session(), dataset_id_str, metadata_args)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 201
|
||||
@@ -157,7 +157,7 @@ class DatasetMetadataServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
metadata = MetadataService.update_metadata_name(db.session(), dataset_id_str, metadata_id_str, payload.name)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 200
|
||||
@@ -192,7 +192,7 @@ class DatasetMetadataServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
|
||||
return "", 204
|
||||
@@ -260,7 +260,7 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
@@ -306,7 +306,7 @@ class DocumentMetadataEditServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
metadata_args = MetadataOperationData.model_validate(service_api_ns.payload or {})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from fields.base import ResponseModel
|
||||
from libs.helper import AppIconUrlField
|
||||
from models.account import TenantStatus
|
||||
from models.model import App, EndUser, Site
|
||||
from services.feature_service import FeatureModel, FeatureService
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
class AppSiteModelConfigResponse(ResponseModel):
|
||||
@@ -38,7 +38,6 @@ class AppSiteResponse(ResponseModel):
|
||||
description: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
default_language: str | None = None
|
||||
prompt_public: bool | None = None
|
||||
@@ -85,7 +84,6 @@ class AppSiteApi(WebApiResource):
|
||||
"description": fields.String,
|
||||
"copyright": fields.String,
|
||||
"privacy_policy": fields.String,
|
||||
"input_placeholder": fields.String,
|
||||
"custom_disclaimer": fields.String,
|
||||
"default_language": fields.String,
|
||||
"prompt_public": fields.Boolean,
|
||||
@@ -129,15 +127,9 @@ class AppSiteApi(WebApiResource):
|
||||
if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE:
|
||||
raise Forbidden()
|
||||
|
||||
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
|
||||
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
|
||||
|
||||
return AppSiteInfo(
|
||||
app_model.tenant,
|
||||
app_model,
|
||||
serialize_runtime_site(site, features),
|
||||
end_user.id,
|
||||
features.can_replace_logo,
|
||||
)
|
||||
return AppSiteInfo(app_model.tenant, app_model, site, end_user.id, can_replace_logo)
|
||||
|
||||
|
||||
class AppSiteInfo:
|
||||
@@ -172,23 +164,7 @@ def serialize_site(site: Site) -> dict[str, Any]:
|
||||
return cast(dict[str, Any], marshal(site, AppSiteApi.site_fields))
|
||||
|
||||
|
||||
def serialize_runtime_site(site: Site, features: FeatureModel) -> dict[str, Any]:
|
||||
site_payload = serialize_site(site)
|
||||
if not features.billing.enabled or features.webapp_copyright_enabled:
|
||||
return site_payload
|
||||
|
||||
site_payload["copyright"] = None
|
||||
site_payload["input_placeholder"] = None
|
||||
return site_payload
|
||||
|
||||
|
||||
def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]:
|
||||
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
|
||||
app_site_info = AppSiteInfo(
|
||||
app_model.tenant,
|
||||
app_model,
|
||||
serialize_runtime_site(site, features),
|
||||
end_user_id,
|
||||
features.can_replace_logo,
|
||||
)
|
||||
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
|
||||
app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, can_replace_logo)
|
||||
return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields))
|
||||
|
||||
@@ -16,7 +16,7 @@ from collections.abc import Generator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, current_app
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import select
|
||||
|
||||
from clients.agent_backend import AgentBackendRunEventAdapter
|
||||
from clients.agent_backend.factory import create_agent_backend_run_client
|
||||
@@ -42,15 +42,7 @@ from core.app.llm.model_access import build_dify_model_access
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App, EndUser, Message
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigSnapshot,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
)
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
@@ -81,15 +73,10 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
inputs = args["inputs"]
|
||||
|
||||
# Resolve the bound roster Agent + its current Agent Soul snapshot.
|
||||
agent, agent_config_id, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type=args.get("draft_type"),
|
||||
user=user,
|
||||
)
|
||||
agent, snapshot, agent_soul = self._resolve_agent(app_model)
|
||||
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
|
||||
invoke_from=invoke_from,
|
||||
snapshot_id=agent_config_id,
|
||||
snapshot_id=snapshot.id,
|
||||
)
|
||||
|
||||
conversation = None
|
||||
@@ -136,7 +123,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
call_depth=0,
|
||||
trace_manager=trace_manager,
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=agent_config_id,
|
||||
agent_config_snapshot_id=snapshot.id,
|
||||
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
|
||||
)
|
||||
|
||||
@@ -192,12 +179,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
persisted to the conversation. Live streaming to a reconnected client is
|
||||
out of scope here — the message is persisted and can be re-fetched.
|
||||
"""
|
||||
agent, agent_config_id, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type="draft",
|
||||
user=user,
|
||||
)
|
||||
agent, snapshot, agent_soul = self._resolve_agent(app_model)
|
||||
conversation = ConversationService.get_conversation(
|
||||
app_model=app_model, conversation_id=conversation_id, user=user
|
||||
)
|
||||
@@ -244,7 +226,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
call_depth=0,
|
||||
trace_manager=trace_manager,
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=agent_config_id,
|
||||
agent_config_snapshot_id=snapshot.id,
|
||||
)
|
||||
|
||||
conversation, message = self._init_generate_records(application_generate_entity, conversation)
|
||||
@@ -439,135 +421,50 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
|
||||
return False, query
|
||||
|
||||
def _resolve_agent(
|
||||
self,
|
||||
app_model: App,
|
||||
*,
|
||||
invoke_from: InvokeFrom,
|
||||
draft_type: Any,
|
||||
user: Account | EndUser,
|
||||
) -> tuple[Agent, str, AgentSoulConfig]:
|
||||
def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
select(Agent).where(
|
||||
Agent.app_id == app_model.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
or_(
|
||||
and_(
|
||||
Agent.app_id == app_model.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
),
|
||||
Agent.backing_app_id == app_model.id,
|
||||
),
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
draft = self._resolve_debug_draft(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent=agent,
|
||||
draft_type=draft_type,
|
||||
account_id=user.id if isinstance(user, Account) else None,
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
return agent, draft.id, agent_soul
|
||||
_, snapshot, agent_soul = self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
return self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
|
||||
)
|
||||
return agent, snapshot.id, agent_soul
|
||||
|
||||
@staticmethod
|
||||
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
|
||||
"""Return the session scope snapshot id for Agent App runtime state.
|
||||
|
||||
Console preview/debug chat uses a stable Agent draft row id; build mode
|
||||
uses the current user's build-draft row id. Published/web/API runs use
|
||||
immutable published snapshot ids. This keeps runtime session continuity
|
||||
inside one editable surface without mixing draft/build/published state.
|
||||
Console preview/debug chat is an editing workspace: saving Agent Soul
|
||||
creates replacement snapshots, but the user expects the same preview
|
||||
conversation to keep context while trying prompt changes. Use a stable
|
||||
NULL snapshot scope for debugger runs so each turn can use the latest
|
||||
Agent Soul while reusing the conversation history. Published/web/API
|
||||
runs keep snapshot-scoped sessions for reproducible runtime state.
|
||||
"""
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
return None
|
||||
return snapshot_id
|
||||
|
||||
@staticmethod
|
||||
def _resolve_debug_draft(
|
||||
*, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None
|
||||
) -> AgentConfigDraft:
|
||||
effective_draft_type = (
|
||||
AgentConfigDraftType.DEBUG_BUILD
|
||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD.value
|
||||
else AgentConfigDraftType.DRAFT
|
||||
)
|
||||
stmt = select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent.id,
|
||||
AgentConfigDraft.draft_type == effective_draft_type,
|
||||
)
|
||||
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
if not account_id:
|
||||
raise AgentAppGeneratorError("Build draft requires an account user")
|
||||
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
|
||||
else:
|
||||
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
|
||||
draft = db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
|
||||
if draft is not None:
|
||||
return draft
|
||||
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
raise AgentAppGeneratorError("Agent build draft not found")
|
||||
_, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id=snapshot.id,
|
||||
config_snapshot=agent_soul,
|
||||
created_by=agent.created_by,
|
||||
updated_by=agent.updated_by,
|
||||
)
|
||||
db.session.add(draft)
|
||||
db.session.flush()
|
||||
return draft
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_by_id(
|
||||
*, tenant_id: str, agent_id: str, snapshot_id: str | None
|
||||
) -> tuple[Agent, AgentConfigSnapshot | AgentConfigDraft, AgentSoulConfig]:
|
||||
) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id))
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent not found")
|
||||
if not snapshot_id:
|
||||
raise AgentAppGeneratorError("Agent has no published version")
|
||||
snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot).where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
AgentConfigSnapshot.id == snapshot_id,
|
||||
)
|
||||
)
|
||||
if snapshot is not None:
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return agent, snapshot, agent_soul
|
||||
draft = db.session.scalar(
|
||||
select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent_id,
|
||||
AgentConfigDraft.id == snapshot_id,
|
||||
)
|
||||
)
|
||||
if draft is None:
|
||||
snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id))
|
||||
if snapshot is None:
|
||||
raise AgentAppGeneratorError("Agent published version not found")
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
return agent, draft, agent_soul
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return agent, snapshot, agent_soul
|
||||
|
||||
|
||||
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
|
||||
|
||||
@@ -22,10 +22,7 @@ from core.entities.provider_entities import (
|
||||
SystemConfigurationStatus,
|
||||
)
|
||||
from core.helper import encrypter
|
||||
from core.helper.model_provider_cache import (
|
||||
ProviderCredentialsCache,
|
||||
ProviderCredentialsCacheType,
|
||||
)
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
|
||||
from core.plugin.impl.model_runtime_factory import create_model_type_instance, create_plugin_model_assembly
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import (
|
||||
@@ -476,39 +473,6 @@ class ProviderConfiguration(BaseModel):
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
return provider_names
|
||||
|
||||
def _invalidate_provider_configuration_cache(
|
||||
self,
|
||||
*,
|
||||
provider_models: bool = False,
|
||||
preferred_model_providers: bool = False,
|
||||
provider_model_settings: bool = False,
|
||||
provider_model_credentials: bool = False,
|
||||
provider_credentials: bool = False,
|
||||
provider_load_balancing_configs: bool = False,
|
||||
) -> None:
|
||||
"""Invalidate tenant-scoped provider snapshots after committing configuration writes."""
|
||||
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
|
||||
|
||||
sources: list[ProviderConfigurationCacheSource] = []
|
||||
if provider_models:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODELS)
|
||||
if preferred_model_providers:
|
||||
sources.append(ProviderConfigurationCacheSource.PREFERRED_MODEL_PROVIDERS)
|
||||
if provider_model_settings:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODEL_SETTINGS)
|
||||
if provider_model_credentials:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODEL_CREDENTIALS)
|
||||
if provider_credentials:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS)
|
||||
if provider_load_balancing_configs:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS)
|
||||
|
||||
if not sources:
|
||||
logger.warning("No provider configuration cache source selected for invalidation")
|
||||
return
|
||||
|
||||
ProviderManager.invalidate_configurations_cache(self.tenant_id, sources=sources)
|
||||
|
||||
def create_provider_credential(self, credentials: dict[str, Any], credential_name: str | None):
|
||||
"""
|
||||
Add custom provider credentials.
|
||||
@@ -525,7 +489,6 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
credentials = self.validate_provider_credentials(credentials=credentials)
|
||||
|
||||
preferred_model_providers_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_record = self._get_provider_record(session)
|
||||
try:
|
||||
@@ -555,9 +518,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.CUSTOM, session=session
|
||||
)
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
|
||||
else:
|
||||
provider_record.is_valid = True
|
||||
|
||||
@@ -572,18 +533,12 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.CUSTOM, session=session
|
||||
)
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
|
||||
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
preferred_model_providers=preferred_model_providers_changed,
|
||||
provider_credentials=True,
|
||||
)
|
||||
|
||||
def update_provider_credential(
|
||||
self,
|
||||
@@ -607,7 +562,6 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
credentials = self.validate_provider_credentials(credentials=credentials, credential_id=credential_id)
|
||||
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_record = self._get_provider_record(session)
|
||||
stmt = select(ProviderCredential).where(
|
||||
@@ -634,7 +588,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
|
||||
self._update_load_balancing_configs_with_credential(
|
||||
credential_id=credential_id,
|
||||
credential_record=credential_record,
|
||||
credential_source=CredentialSourceType.PROVIDER,
|
||||
@@ -643,10 +597,6 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def _update_load_balancing_configs_with_credential(
|
||||
self,
|
||||
@@ -654,7 +604,7 @@ class ProviderConfiguration(BaseModel):
|
||||
credential_record: ProviderCredential | ProviderModelCredential,
|
||||
credential_source: str,
|
||||
session: Session,
|
||||
) -> bool:
|
||||
):
|
||||
"""
|
||||
Update load balancing configurations that reference the given credential_id.
|
||||
|
||||
@@ -675,7 +625,7 @@ class ProviderConfiguration(BaseModel):
|
||||
load_balancing_configs = session.execute(stmt).scalars().all()
|
||||
|
||||
if not load_balancing_configs:
|
||||
return False
|
||||
return
|
||||
|
||||
# Update each load balancing config with the new credentials
|
||||
for lb_config in load_balancing_configs:
|
||||
@@ -693,7 +643,6 @@ class ProviderConfiguration(BaseModel):
|
||||
lb_credentials_cache.delete()
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
def delete_provider_credential(self, credential_id: str):
|
||||
"""
|
||||
@@ -702,8 +651,6 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
preferred_model_providers_changed = False
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderCredential).where(
|
||||
ProviderCredential.id == credential_id,
|
||||
@@ -724,7 +671,6 @@ class ProviderConfiguration(BaseModel):
|
||||
LoadBalancingModelConfig.credential_source_type == CredentialSourceType.PROVIDER,
|
||||
)
|
||||
lb_configs_using_credential = session.execute(lb_stmt).scalars().all()
|
||||
load_balancing_configs_changed = bool(lb_configs_using_credential)
|
||||
try:
|
||||
for lb_config in lb_configs_using_credential:
|
||||
lb_credentials_cache = ProviderCredentialsCache(
|
||||
@@ -757,9 +703,7 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.SYSTEM, session=session
|
||||
)
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.SYSTEM, session=session)
|
||||
elif provider_record and provider_record.credential_id == credential_id:
|
||||
provider_record.credential_id = None
|
||||
provider_record.updated_at = naive_utc_now()
|
||||
@@ -770,19 +714,12 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.SYSTEM, session=session
|
||||
)
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.SYSTEM, session=session)
|
||||
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
preferred_model_providers=preferred_model_providers_changed,
|
||||
provider_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def switch_active_provider_credential(self, credential_id: str):
|
||||
"""
|
||||
@@ -791,7 +728,6 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
preferred_model_providers_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderCredential).where(
|
||||
ProviderCredential.id == credential_id,
|
||||
@@ -817,14 +753,10 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
ProviderType.CUSTOM, session=session
|
||||
)
|
||||
self.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
if preferred_model_providers_changed:
|
||||
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
|
||||
|
||||
def _get_custom_model_record(
|
||||
self,
|
||||
@@ -1085,10 +1017,6 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
)
|
||||
|
||||
def update_custom_model_credential(
|
||||
self,
|
||||
@@ -1125,7 +1053,6 @@ class ProviderConfiguration(BaseModel):
|
||||
credential_id=credential_id,
|
||||
)
|
||||
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session)
|
||||
|
||||
@@ -1155,7 +1082,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
|
||||
self._update_load_balancing_configs_with_credential(
|
||||
credential_id=credential_id,
|
||||
credential_record=credential_record,
|
||||
credential_source=CredentialSourceType.CUSTOM_MODEL,
|
||||
@@ -1164,11 +1091,6 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def delete_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@@ -1177,7 +1099,6 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderModelCredential).where(
|
||||
ProviderModelCredential.id == credential_id,
|
||||
@@ -1197,7 +1118,6 @@ class ProviderConfiguration(BaseModel):
|
||||
LoadBalancingModelConfig.credential_source_type == CredentialSourceType.CUSTOM_MODEL,
|
||||
)
|
||||
lb_configs_using_credential = session.execute(lb_stmt).scalars().all()
|
||||
load_balancing_configs_changed = bool(lb_configs_using_credential)
|
||||
|
||||
try:
|
||||
for lb_config in lb_configs_using_credential:
|
||||
@@ -1241,11 +1161,6 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def add_model_credential_to_model(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@@ -1298,7 +1213,6 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
session.add(provider_model_record)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def switch_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@@ -1337,7 +1251,6 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.MODEL,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def delete_custom_model(self, model_type: ModelType, model: str):
|
||||
"""
|
||||
@@ -1346,7 +1259,6 @@ class ProviderConfiguration(BaseModel):
|
||||
:param model: model name
|
||||
:return:
|
||||
"""
|
||||
provider_models_changed = False
|
||||
with Session(db.engine) as session:
|
||||
# get provider model
|
||||
provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session)
|
||||
@@ -1355,7 +1267,6 @@ class ProviderConfiguration(BaseModel):
|
||||
if provider_model_record:
|
||||
session.delete(provider_model_record)
|
||||
session.commit()
|
||||
provider_models_changed = True
|
||||
|
||||
provider_model_credentials_cache = ProviderCredentialsCache(
|
||||
tenant_id=self.tenant_id,
|
||||
@@ -1364,8 +1275,6 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
|
||||
provider_model_credentials_cache.delete()
|
||||
if provider_models_changed:
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def _get_provider_model_setting(
|
||||
self, model_type: ModelType, model: str, session: Session
|
||||
@@ -1405,7 +1314,6 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1432,7 +1340,6 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1485,7 +1392,6 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1513,7 +1419,6 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1549,19 +1454,19 @@ class ProviderConfiguration(BaseModel):
|
||||
credentials=credentials or {},
|
||||
)
|
||||
|
||||
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None) -> bool:
|
||||
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None):
|
||||
"""
|
||||
Switch preferred provider type.
|
||||
:param provider_type:
|
||||
:return:
|
||||
"""
|
||||
if provider_type == self.preferred_provider_type:
|
||||
return False
|
||||
return
|
||||
|
||||
if provider_type == ProviderType.SYSTEM and not self.system_configuration.enabled:
|
||||
return False
|
||||
return
|
||||
|
||||
def _switch(s: Session) -> bool:
|
||||
def _switch(s: Session):
|
||||
stmt = select(TenantPreferredModelProvider).where(
|
||||
TenantPreferredModelProvider.tenant_id == self.tenant_id,
|
||||
TenantPreferredModelProvider.provider_name.in_(self._get_provider_names()),
|
||||
@@ -1578,16 +1483,12 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
s.add(preferred_model_provider)
|
||||
s.commit()
|
||||
return True
|
||||
|
||||
if session:
|
||||
return _switch(session)
|
||||
else:
|
||||
with Session(db.engine) as session:
|
||||
changed = _switch(session)
|
||||
if changed:
|
||||
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
|
||||
return changed
|
||||
return _switch(session)
|
||||
|
||||
def extract_secret_variables(self, credential_form_schemas: list[CredentialFormSchema]) -> list[str]:
|
||||
"""
|
||||
|
||||
+55
-620
@@ -1,14 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import StrEnum
|
||||
from collections.abc import Sequence
|
||||
from json import JSONDecodeError
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
@@ -45,7 +41,6 @@ from graphon.model_runtime.entities.provider_entities import (
|
||||
ProviderEntity,
|
||||
)
|
||||
from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
|
||||
from models.enums import CredentialSourceType
|
||||
from models.provider import (
|
||||
LoadBalancingModelConfig,
|
||||
Provider,
|
||||
@@ -64,494 +59,7 @@ if TYPE_CHECKING:
|
||||
from graphon.model_runtime.protocols.runtime import ModelRuntime
|
||||
from models.account import Account
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_credentials_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any])
|
||||
_PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS = 300
|
||||
_PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS = 360
|
||||
_PROVIDER_CONFIGURATION_CACHE_VERSION_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:version"
|
||||
_PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:v:{version}"
|
||||
|
||||
|
||||
class ProviderConfigurationCacheSource(StrEnum):
|
||||
PROVIDER_MODELS = "provider_models"
|
||||
PREFERRED_MODEL_PROVIDERS = "preferred_model_providers"
|
||||
PROVIDER_MODEL_SETTINGS = "provider_model_settings"
|
||||
PROVIDER_MODEL_CREDENTIALS = "provider_model_credentials"
|
||||
PROVIDER_CREDENTIALS = "provider_credentials"
|
||||
PROVIDER_LOAD_BALANCING_CONFIGS = "provider_load_balancing_configs"
|
||||
|
||||
|
||||
_PROVIDER_CONFIGURATION_SOURCES = tuple(ProviderConfigurationCacheSource)
|
||||
|
||||
|
||||
class _CacheEntry(Protocol):
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> Self: ...
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderConfigurationCacheSourceSpec[T: _CacheEntry]:
|
||||
name: ProviderConfigurationCacheSource
|
||||
entry_cls: type[T]
|
||||
load_records: Callable[[str], list[T]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
credential_id: str | None
|
||||
credential_name: str | None
|
||||
encrypted_config: str | None
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModel) -> _ProviderModelCacheEntry:
|
||||
credential = record.__dict__.get("credential")
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
credential_id=record.credential_id,
|
||||
credential_name=credential.credential_name if credential else None,
|
||||
encrypted_config=credential.encrypted_config if credential else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
credential_id=row.get("credential_id"),
|
||||
credential_name=row.get("credential_name"),
|
||||
encrypted_config=row.get("encrypted_config"),
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
row = asdict(self)
|
||||
row["model_type"] = self.model_type.value
|
||||
return row
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TenantPreferredModelProviderCacheEntry:
|
||||
provider_name: str
|
||||
preferred_provider_type: ProviderType
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: TenantPreferredModelProvider) -> _TenantPreferredModelProviderCacheEntry:
|
||||
return cls(
|
||||
provider_name=record.provider_name,
|
||||
preferred_provider_type=record.preferred_provider_type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _TenantPreferredModelProviderCacheEntry:
|
||||
return cls(
|
||||
provider_name=row["provider_name"],
|
||||
preferred_provider_type=ProviderType(row["preferred_provider_type"]),
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"preferred_provider_type": self.preferred_provider_type.value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelSettingCacheEntry:
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
enabled: bool
|
||||
load_balancing_enabled: bool
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModelSetting) -> _ProviderModelSettingCacheEntry:
|
||||
return cls(
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
enabled=record.enabled,
|
||||
load_balancing_enabled=record.load_balancing_enabled,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelSettingCacheEntry:
|
||||
return cls(
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
enabled=row["enabled"],
|
||||
load_balancing_enabled=row["load_balancing_enabled"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"enabled": self.enabled,
|
||||
"load_balancing_enabled": self.load_balancing_enabled,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelCredentialCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
credential_name: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModelCredential) -> _ProviderModelCredentialCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
credential_name=record.credential_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelCredentialCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
credential_name=row["credential_name"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"credential_name": self.credential_name,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderCredentialCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
credential_name: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderCredential) -> _ProviderCredentialCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
credential_name=record.credential_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderCredentialCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
credential_name=row["credential_name"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LoadBalancingModelConfigCacheEntry:
|
||||
id: str
|
||||
tenant_id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
name: str
|
||||
encrypted_config: str | None
|
||||
credential_id: str | None
|
||||
credential_source_type: CredentialSourceType | None
|
||||
enabled: bool
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: LoadBalancingModelConfig) -> _LoadBalancingModelConfigCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
tenant_id=record.tenant_id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
name=record.name,
|
||||
encrypted_config=record.encrypted_config,
|
||||
credential_id=record.credential_id,
|
||||
credential_source_type=record.credential_source_type,
|
||||
enabled=record.enabled,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _LoadBalancingModelConfigCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
tenant_id=row["tenant_id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
name=row["name"],
|
||||
encrypted_config=row.get("encrypted_config"),
|
||||
credential_id=row.get("credential_id"),
|
||||
credential_source_type=CredentialSourceType(row["credential_source_type"])
|
||||
if row.get("credential_source_type")
|
||||
else None,
|
||||
enabled=row["enabled"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"name": self.name,
|
||||
"encrypted_config": self.encrypted_config,
|
||||
"credential_id": self.credential_id,
|
||||
"credential_source_type": self.credential_source_type.value if self.credential_source_type else None,
|
||||
"enabled": self.enabled,
|
||||
}
|
||||
|
||||
|
||||
class _ProviderConfigurationSourceCache:
|
||||
"""Redis-backed cache for tenant provider DB cache entries.
|
||||
|
||||
The assembled ``ProviderConfigurations`` object is intentionally not cached
|
||||
here because it carries request-scoped runtime bindings. Cache only the DB
|
||||
rows that are stable enough to reuse across processes, then let each
|
||||
``ProviderManager`` assemble and bind fresh runtime-aware entities.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_records[T: _CacheEntry](
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source: ProviderConfigurationCacheSource,
|
||||
entry_cls: type[T],
|
||||
) -> tuple[list[T] | None, str | None]:
|
||||
version: str | None = None
|
||||
try:
|
||||
version = cls._get_version(tenant_id=tenant_id, source=source)
|
||||
cache_key = cls._source_key(tenant_id=tenant_id, source=source, version=version)
|
||||
cached_records = redis_client.get(cache_key)
|
||||
if cached_records is None:
|
||||
return None, version
|
||||
|
||||
cached_text = cached_records.decode("utf-8") if isinstance(cached_records, bytes) else cached_records
|
||||
rows = json.loads(cached_text)
|
||||
if not isinstance(rows, list):
|
||||
return None, version
|
||||
|
||||
return [entry_cls.from_cache_row(row) for row in rows if isinstance(row, dict)], version
|
||||
except Exception:
|
||||
logger.warning("Failed to read provider configuration source cache", exc_info=True)
|
||||
return None, version
|
||||
|
||||
@classmethod
|
||||
def set_records(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source: ProviderConfigurationCacheSource,
|
||||
records: Sequence[_CacheEntry],
|
||||
expected_version: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
version = cls._get_version(tenant_id=tenant_id, source=source)
|
||||
if expected_version is not None and version != expected_version:
|
||||
return
|
||||
cache_key = cls._source_key(tenant_id=tenant_id, source=source, version=version)
|
||||
rows = [record.to_cache_row() for record in records]
|
||||
redis_client.setex(cache_key, _PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS, json.dumps(rows))
|
||||
except Exception:
|
||||
logger.warning("Failed to write provider configuration source cache", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def invalidate_tenant(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
sources: Sequence[ProviderConfigurationCacheSource] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
if sources is None:
|
||||
sources = _PROVIDER_CONFIGURATION_SOURCES
|
||||
for source in sources:
|
||||
version_key = _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY.format(tenant_id=tenant_id, source=source.value)
|
||||
redis_client.incr(version_key)
|
||||
redis_client.expire(version_key, _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
except Exception:
|
||||
logger.warning("Failed to invalidate provider configuration source cache", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def _get_version(cls, *, tenant_id: str, source: ProviderConfigurationCacheSource) -> str:
|
||||
version_key = _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY.format(tenant_id=tenant_id, source=source.value)
|
||||
version = redis_client.get(version_key)
|
||||
if version is None:
|
||||
redis_client.set(version_key, "0", ex=_PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
return "0"
|
||||
redis_client.expire(version_key, _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
return version.decode("utf-8") if isinstance(version, bytes) else str(version)
|
||||
|
||||
@staticmethod
|
||||
def _source_key(*, tenant_id: str, source: ProviderConfigurationCacheSource, version: str) -> str:
|
||||
return _PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY.format(
|
||||
tenant_id=tenant_id,
|
||||
source=source.value,
|
||||
version=version,
|
||||
)
|
||||
|
||||
|
||||
def _get_cached_or_load_records[T: _CacheEntry](
|
||||
*,
|
||||
tenant_id: str,
|
||||
cache_source: _ProviderConfigurationCacheSourceSpec[T],
|
||||
) -> list[T]:
|
||||
cached_records, cache_version = _ProviderConfigurationSourceCache.get_records(
|
||||
tenant_id=tenant_id,
|
||||
source=cache_source.name,
|
||||
entry_cls=cache_source.entry_cls,
|
||||
)
|
||||
if cached_records is not None:
|
||||
return cached_records
|
||||
|
||||
records = cache_source.load_records(tenant_id)
|
||||
_ProviderConfigurationSourceCache.set_records(
|
||||
tenant_id=tenant_id,
|
||||
source=cache_source.name,
|
||||
records=records,
|
||||
expected_version=cache_version,
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _attach_active_credentials(
|
||||
*,
|
||||
session: Any,
|
||||
records: Sequence[Provider | ProviderModel],
|
||||
credential_model_cls: type[ProviderCredential | ProviderModelCredential],
|
||||
) -> None:
|
||||
credential_ids = [record.credential_id for record in records if getattr(record, "credential_id", None)]
|
||||
if not credential_ids:
|
||||
return
|
||||
|
||||
credentials = session.scalars(select(credential_model_cls).where(credential_model_cls.id.in_(credential_ids))).all()
|
||||
credential_by_id = {credential.id: credential for credential in credentials}
|
||||
for record in records:
|
||||
if getattr(record, "credential_id", None):
|
||||
record.__dict__["credential"] = credential_by_id.get(record.credential_id)
|
||||
|
||||
|
||||
def _load_provider_model_cache_entries(tenant_id: str) -> list[_ProviderModelCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
|
||||
provider_models = list(session.scalars(stmt))
|
||||
_attach_active_credentials(
|
||||
session=session,
|
||||
records=provider_models,
|
||||
credential_model_cls=ProviderModelCredential,
|
||||
)
|
||||
return [_ProviderModelCacheEntry.from_record(provider_model) for provider_model in provider_models]
|
||||
|
||||
|
||||
def _load_preferred_model_provider_cache_entries(tenant_id: str) -> list[_TenantPreferredModelProviderCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
|
||||
return [
|
||||
_TenantPreferredModelProviderCacheEntry.from_record(preferred_model_provider)
|
||||
for preferred_model_provider in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_model_setting_cache_entries(tenant_id: str) -> list[_ProviderModelSettingCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
|
||||
return [
|
||||
_ProviderModelSettingCacheEntry.from_record(provider_model_setting)
|
||||
for provider_model_setting in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_model_credential_cache_entries(tenant_id: str) -> list[_ProviderModelCredentialCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(ProviderModelCredential)
|
||||
.where(ProviderModelCredential.tenant_id == tenant_id)
|
||||
.order_by(ProviderModelCredential.created_at.desc())
|
||||
)
|
||||
return [
|
||||
_ProviderModelCredentialCacheEntry.from_record(provider_model_credential)
|
||||
for provider_model_credential in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_credential_cache_entries(tenant_id: str) -> list[_ProviderCredentialCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(ProviderCredential)
|
||||
.where(ProviderCredential.tenant_id == tenant_id)
|
||||
.order_by(ProviderCredential.created_at.desc())
|
||||
)
|
||||
return [
|
||||
_ProviderCredentialCacheEntry.from_record(provider_credential)
|
||||
for provider_credential in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_load_balancing_config_cache_entries(tenant_id: str) -> list[_LoadBalancingModelConfigCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
|
||||
return [
|
||||
_LoadBalancingModelConfigCacheEntry.from_record(load_balancing_model_config)
|
||||
for load_balancing_model_config in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
_PROVIDER_MODELS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODELS,
|
||||
entry_cls=_ProviderModelCacheEntry,
|
||||
load_records=_load_provider_model_cache_entries,
|
||||
)
|
||||
_PREFERRED_MODEL_PROVIDERS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PREFERRED_MODEL_PROVIDERS,
|
||||
entry_cls=_TenantPreferredModelProviderCacheEntry,
|
||||
load_records=_load_preferred_model_provider_cache_entries,
|
||||
)
|
||||
_PROVIDER_MODEL_SETTINGS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODEL_SETTINGS,
|
||||
entry_cls=_ProviderModelSettingCacheEntry,
|
||||
load_records=_load_provider_model_setting_cache_entries,
|
||||
)
|
||||
_PROVIDER_MODEL_CREDENTIALS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODEL_CREDENTIALS,
|
||||
entry_cls=_ProviderModelCredentialCacheEntry,
|
||||
load_records=_load_provider_model_credential_cache_entries,
|
||||
)
|
||||
_PROVIDER_CREDENTIALS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,
|
||||
entry_cls=_ProviderCredentialCacheEntry,
|
||||
load_records=_load_provider_credential_cache_entries,
|
||||
)
|
||||
_PROVIDER_LOAD_BALANCING_CONFIGS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,
|
||||
entry_cls=_LoadBalancingModelConfigCacheEntry,
|
||||
load_records=_load_provider_load_balancing_config_cache_entries,
|
||||
)
|
||||
|
||||
|
||||
class ProviderManager:
|
||||
@@ -590,14 +98,6 @@ class ProviderManager:
|
||||
|
||||
self._configurations_cache.pop(tenant_id, None)
|
||||
|
||||
@staticmethod
|
||||
def invalidate_configurations_cache(
|
||||
tenant_id: str,
|
||||
sources: Sequence[ProviderConfigurationCacheSource] | None = None,
|
||||
) -> None:
|
||||
"""Invalidate cross-process provider configuration source cache for a tenant."""
|
||||
_ProviderConfigurationSourceCache.invalidate_tenant(tenant_id, sources=sources)
|
||||
|
||||
def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
|
||||
"""
|
||||
Get model provider configurations.
|
||||
@@ -692,9 +192,6 @@ class ProviderManager:
|
||||
# Get All provider model credentials
|
||||
provider_name_to_provider_model_credentials_dict = self._get_all_provider_model_credentials(tenant_id)
|
||||
|
||||
# Get All provider credentials
|
||||
provider_name_to_provider_credentials_dict = self._get_all_provider_credentials(tenant_id)
|
||||
|
||||
provider_configurations = ProviderConfigurations(tenant_id=tenant_id)
|
||||
|
||||
# Construct ProviderConfiguration objects for each provider
|
||||
@@ -727,12 +224,7 @@ class ProviderManager:
|
||||
|
||||
# Convert to custom configuration
|
||||
custom_configuration = self._to_custom_configuration(
|
||||
tenant_id,
|
||||
provider_entity,
|
||||
provider_records,
|
||||
provider_model_records,
|
||||
provider_model_credentials,
|
||||
provider_name_to_provider_credentials_dict,
|
||||
tenant_id, provider_entity, provider_records, provider_model_records, provider_model_credentials
|
||||
)
|
||||
|
||||
# Convert to system configuration
|
||||
@@ -956,115 +448,84 @@ class ProviderManager:
|
||||
provider_name_to_provider_records_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(Provider).where(Provider.tenant_id == tenant_id, Provider.is_valid == True)
|
||||
providers = list(session.scalars(stmt))
|
||||
_attach_active_credentials(
|
||||
session=session,
|
||||
records=providers,
|
||||
credential_model_cls=ProviderCredential,
|
||||
)
|
||||
providers = session.scalars(stmt)
|
||||
for provider in providers:
|
||||
# Use provider name with prefix after the data migration
|
||||
provider_name_to_provider_records_dict[str(ModelProviderID(provider.provider_name))].append(provider)
|
||||
return provider_name_to_provider_records_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_models(tenant_id: str) -> dict[str, list[_ProviderModelCacheEntry]]:
|
||||
def _get_all_provider_models(tenant_id: str) -> dict[str, list[ProviderModel]]:
|
||||
"""
|
||||
Get all provider model records of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_models = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODELS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_records_dict = defaultdict(list)
|
||||
for provider_model in provider_models:
|
||||
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
|
||||
provider_models = session.scalars(stmt)
|
||||
for provider_model in provider_models:
|
||||
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
|
||||
return provider_name_to_provider_model_records_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, _TenantPreferredModelProviderCacheEntry]:
|
||||
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, TenantPreferredModelProvider]:
|
||||
"""
|
||||
Get All preferred provider types of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
preferred_provider_types = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PREFERRED_MODEL_PROVIDERS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
return {
|
||||
preferred_provider_type.provider_name: preferred_provider_type
|
||||
for preferred_provider_type in preferred_provider_types
|
||||
}
|
||||
provider_name_to_preferred_provider_type_records_dict = {}
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
|
||||
preferred_provider_types = session.scalars(stmt)
|
||||
provider_name_to_preferred_provider_type_records_dict = {
|
||||
preferred_provider_type.provider_name: preferred_provider_type
|
||||
for preferred_provider_type in preferred_provider_types
|
||||
}
|
||||
return provider_name_to_preferred_provider_type_records_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[_ProviderModelSettingCacheEntry]]:
|
||||
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[ProviderModelSetting]]:
|
||||
"""
|
||||
Get All provider model settings of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_model_settings = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODEL_SETTINGS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_settings_dict = defaultdict(list)
|
||||
for provider_model_setting in provider_model_settings:
|
||||
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
|
||||
provider_model_setting
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
|
||||
provider_model_settings = session.scalars(stmt)
|
||||
for provider_model_setting in provider_model_settings:
|
||||
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
|
||||
provider_model_setting
|
||||
)
|
||||
return provider_name_to_provider_model_settings_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[_ProviderModelCredentialCacheEntry]]:
|
||||
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[ProviderModelCredential]]:
|
||||
"""
|
||||
Get All provider model credentials of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_model_credentials = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODEL_CREDENTIALS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_credentials_dict = defaultdict(list)
|
||||
for provider_model_credential in provider_model_credentials:
|
||||
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
|
||||
provider_model_credential
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelCredential).where(ProviderModelCredential.tenant_id == tenant_id)
|
||||
provider_model_credentials = session.scalars(stmt)
|
||||
for provider_model_credential in provider_model_credentials:
|
||||
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
|
||||
provider_model_credential
|
||||
)
|
||||
return provider_name_to_provider_model_credentials_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_credentials(tenant_id: str) -> dict[str, list[_ProviderCredentialCacheEntry]]:
|
||||
"""
|
||||
Get All provider credentials of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_credentials = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_CREDENTIALS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_credentials_dict = defaultdict(list)
|
||||
for provider_credential in provider_credentials:
|
||||
provider_name_to_provider_credentials_dict[provider_credential.provider_name].append(provider_credential)
|
||||
return provider_name_to_provider_credentials_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_load_balancing_configs(
|
||||
tenant_id: str,
|
||||
) -> dict[str, list[_LoadBalancingModelConfigCacheEntry]]:
|
||||
def _get_all_provider_load_balancing_configs(tenant_id: str) -> dict[str, list[LoadBalancingModelConfig]]:
|
||||
"""
|
||||
Get All provider load balancing configs of the workspace.
|
||||
|
||||
@@ -1085,16 +546,14 @@ class ProviderManager:
|
||||
if not model_load_balancing_enabled:
|
||||
return {}
|
||||
|
||||
provider_load_balancing_configs = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_LOAD_BALANCING_CONFIGS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_load_balancing_model_configs_dict = defaultdict(list)
|
||||
for provider_load_balancing_config in provider_load_balancing_configs:
|
||||
provider_name_to_provider_load_balancing_model_configs_dict[
|
||||
provider_load_balancing_config.provider_name
|
||||
].append(provider_load_balancing_config)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
|
||||
provider_load_balancing_configs = session.scalars(stmt)
|
||||
for provider_load_balancing_config in provider_load_balancing_configs:
|
||||
provider_name_to_provider_load_balancing_model_configs_dict[
|
||||
provider_load_balancing_config.provider_name
|
||||
].append(provider_load_balancing_config)
|
||||
|
||||
return provider_name_to_provider_load_balancing_model_configs_dict
|
||||
|
||||
@@ -1263,9 +722,8 @@ class ProviderManager:
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_records: list[Provider],
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
provider_model_credentials: list[_ProviderModelCredentialCacheEntry],
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
provider_model_records: list[ProviderModel],
|
||||
provider_model_credentials: list[ProviderModelCredential],
|
||||
) -> CustomConfiguration:
|
||||
"""
|
||||
Convert to custom configuration.
|
||||
@@ -1278,10 +736,7 @@ class ProviderManager:
|
||||
"""
|
||||
# Get custom provider configuration
|
||||
custom_provider_configuration = self._get_custom_provider_configuration(
|
||||
tenant_id,
|
||||
provider_entity,
|
||||
provider_records,
|
||||
provider_credentials_by_name,
|
||||
tenant_id, provider_entity, provider_records
|
||||
)
|
||||
|
||||
# Get custom models which have not been added to the model list yet
|
||||
@@ -1303,11 +758,7 @@ class ProviderManager:
|
||||
)
|
||||
|
||||
def _get_custom_provider_configuration(
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_records: list[Provider],
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
self, tenant_id: str, provider_entity: ProviderEntity, provider_records: list[Provider]
|
||||
) -> CustomProviderConfiguration | None:
|
||||
"""Get custom provider configuration."""
|
||||
# Find custom provider record (non-system)
|
||||
@@ -1339,29 +790,13 @@ class ProviderManager:
|
||||
credentials=provider_credentials,
|
||||
current_credential_name=custom_provider_record.credential_name,
|
||||
current_credential_id=custom_provider_record.credential_id,
|
||||
available_credentials=self._get_provider_available_credentials_from_records(
|
||||
custom_provider_record.provider_name,
|
||||
provider_credentials_by_name,
|
||||
available_credentials=self.get_provider_available_credentials(
|
||||
tenant_id, custom_provider_record.provider_name
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_provider_available_credentials_from_records(
|
||||
provider_name: str,
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
) -> list[CredentialConfiguration]:
|
||||
available_credentials: list[CredentialConfiguration] = []
|
||||
for candidate_provider_name in ProviderManager._get_provider_names(provider_name):
|
||||
available_credentials.extend(
|
||||
CredentialConfiguration(credential_id=credential.id, credential_name=credential.credential_name)
|
||||
for credential in provider_credentials_by_name.get(candidate_provider_name, [])
|
||||
)
|
||||
return available_credentials
|
||||
|
||||
def _get_can_added_models(
|
||||
self,
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
|
||||
self, provider_model_records: list[ProviderModel], all_model_credentials: Sequence[ProviderModelCredential]
|
||||
) -> list[dict]:
|
||||
"""Get the custom models and credentials from enterprise version which haven't add to the model list"""
|
||||
existing_model_set = {(record.model_name, record.model_type) for record in provider_model_records}
|
||||
@@ -1394,9 +829,9 @@ class ProviderManager:
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
provider_model_records: list[ProviderModel],
|
||||
can_added_models: list[dict],
|
||||
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
|
||||
all_model_credentials: Sequence[ProviderModelCredential],
|
||||
) -> list[CustomModelConfiguration]:
|
||||
"""Get custom model configurations."""
|
||||
# Get model credential secret variables
|
||||
@@ -1716,8 +1151,8 @@ class ProviderManager:
|
||||
def _to_model_settings(
|
||||
self,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_model_settings: list[_ProviderModelSettingCacheEntry] | None = None,
|
||||
load_balancing_model_configs: list[_LoadBalancingModelConfigCacheEntry] | None = None,
|
||||
provider_model_settings: list[ProviderModelSetting] | None = None,
|
||||
load_balancing_model_configs: list[LoadBalancingModelConfig] | None = None,
|
||||
) -> list[ModelSettings]:
|
||||
"""
|
||||
Convert to model settings.
|
||||
|
||||
@@ -31,7 +31,6 @@ from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, Wo
|
||||
from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent
|
||||
from graphon.nodes.base.node import Node
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
|
||||
from services.agent.prompt_mentions import extract_workflow_node_output_selectors
|
||||
|
||||
from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason
|
||||
from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
|
||||
@@ -689,20 +688,5 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
node_id: str,
|
||||
node_data: DifyAgentNodeData,
|
||||
) -> Mapping[str, Sequence[str]]:
|
||||
"""Reuse frontend workflow-marker parsing for graph variable loading.
|
||||
|
||||
This follows the same marker parser used by publish sync and runtime
|
||||
request building, including reserved-prefix exclusion.
|
||||
"""
|
||||
del graph_config
|
||||
|
||||
agent_task = (
|
||||
node_data.get("agent_task") if isinstance(node_data, Mapping) else getattr(node_data, "agent_task", None)
|
||||
)
|
||||
if not isinstance(agent_task, str):
|
||||
return {}
|
||||
|
||||
return {
|
||||
f"{node_id}.{'.'.join(selector)}": list(selector)
|
||||
for selector in extract_workflow_node_output_selectors(agent_task)
|
||||
}
|
||||
del graph_config, node_id, node_data
|
||||
return {}
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
|
||||
|
||||
SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
|
||||
{
|
||||
@@ -49,7 +48,9 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
)
|
||||
|
||||
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
|
||||
reserved_status["knowledge"] = "supported_by_knowledge_layer" if agent_soul.knowledge.sets else "not_configured"
|
||||
reserved_status["knowledge"] = (
|
||||
"supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured"
|
||||
)
|
||||
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
|
||||
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
|
||||
reserved_status["env"] = "supported_by_shell_bootstrap"
|
||||
@@ -65,14 +66,14 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
|
||||
|
||||
def list_configured_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
|
||||
"""Return normalized dataset ids selected by Agent v2 knowledge sets.
|
||||
"""Return the normalized knowledge dataset ids that can produce a runtime layer.
|
||||
|
||||
``build_runtime_feature_manifest()`` and ``build_knowledge_layer_config()``
|
||||
stay aligned on the set-based contract: DTO validation rejects blank dataset
|
||||
ids before runtime, so this helper only flattens configured set datasets for
|
||||
metadata/diagnostic surfaces that still need a dataset-id summary.
|
||||
must stay aligned: both decide knowledge support from this effective,
|
||||
non-blank dataset-id set rather than from raw
|
||||
``agent_soul.knowledge.datasets`` entries.
|
||||
"""
|
||||
return list_agent_soul_knowledge_dataset_ids(agent_soul)
|
||||
return [dataset_id for dataset in agent_soul.knowledge.datasets if (dataset_id := (dataset.id or "").strip())]
|
||||
|
||||
|
||||
def _get_nested(value: dict[str, Any], path: str) -> Any:
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, Protocol, assert_never, cast
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.agent_stub.protocol import AgentStubFileMapping
|
||||
from dify_agent.layers.ask_human import DifyAskHumanLayerConfig
|
||||
from dify_agent.layers.drive import (
|
||||
DifyDriveLayerConfig,
|
||||
@@ -17,16 +15,7 @@ from dify_agent.layers.execution_context import (
|
||||
DifyExecutionContextLayerConfig,
|
||||
DifyExecutionContextUserFrom,
|
||||
)
|
||||
from dify_agent.layers.knowledge import (
|
||||
DifyKnowledgeBaseLayerConfig,
|
||||
DifyKnowledgeDatasetConfig,
|
||||
DifyKnowledgeMetadataFilteringConfig,
|
||||
DifyKnowledgeModelConfig,
|
||||
DifyKnowledgeQueryConfig,
|
||||
DifyKnowledgeRerankingModelConfig,
|
||||
DifyKnowledgeRetrievalConfig,
|
||||
DifyKnowledgeSetConfig,
|
||||
)
|
||||
from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig, DifyKnowledgeRetrievalConfig
|
||||
from dify_agent.layers.shell import (
|
||||
DifyShellCliToolConfig,
|
||||
DifyShellEnvVarConfig,
|
||||
@@ -35,7 +24,7 @@ from dify_agent.layers.shell import (
|
||||
DifyShellSecretRefConfig,
|
||||
)
|
||||
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic import BaseModel
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendModelConfig,
|
||||
@@ -47,13 +36,11 @@ from clients.agent_backend import (
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
from graphon.file import File, FileTransferMethod
|
||||
from graphon.file import FileTransferMethod
|
||||
from graphon.variables.segments import Segment
|
||||
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
|
||||
from models.agent_config_entities import (
|
||||
AgentKnowledgeMetadataFilteringConfig,
|
||||
AgentKnowledgeModelConfig,
|
||||
AgentKnowledgeRetrievalConfig,
|
||||
AgentKnowledgeQueryConfig,
|
||||
AgentSoulConfig,
|
||||
DeclaredArrayItem,
|
||||
DeclaredOutputChildConfig,
|
||||
@@ -71,16 +58,13 @@ from services.agent.prompt_mentions import (
|
||||
build_node_job_mention_resolver,
|
||||
build_soul_mention_resolver,
|
||||
expand_prompt_mentions,
|
||||
extract_workflow_node_output_selectors,
|
||||
normalize_previous_node_output_selector,
|
||||
parse_prompt_mentions,
|
||||
workflow_previous_node_output_refs_from_selectors,
|
||||
)
|
||||
from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref
|
||||
|
||||
from .output_failure_orchestrator import retry_idempotency_key
|
||||
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
|
||||
from .runtime_feature_manifest import build_runtime_feature_manifest
|
||||
from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids
|
||||
|
||||
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
|
||||
_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation")
|
||||
@@ -90,13 +74,6 @@ _DANGEROUS_ACK_KEYS = (
|
||||
"risk_accepted",
|
||||
"approved",
|
||||
)
|
||||
type AgentStubFileTransferMethod = Literal["local_file", "tool_file", "datasource_file", "remote_url"]
|
||||
_AGENT_STUB_FILE_TRANSFER_METHODS: Mapping[FileTransferMethod, AgentStubFileTransferMethod] = {
|
||||
FileTransferMethod.LOCAL_FILE: "local_file",
|
||||
FileTransferMethod.TOOL_FILE: "tool_file",
|
||||
FileTransferMethod.DATASOURCE_FILE: "datasource_file",
|
||||
FileTransferMethod.REMOTE_URL: "remote_url",
|
||||
}
|
||||
|
||||
|
||||
class WorkflowAgentRuntimeRequestBuildError(ValueError):
|
||||
@@ -149,9 +126,6 @@ class WorkflowAgentRuntimeRequest:
|
||||
class WorkflowAgentRuntimeRequestBuilder:
|
||||
"""Build public Dify Agent run requests from workflow Agent v2 runtime state."""
|
||||
|
||||
_WORKFLOW_USER_PROMPT_FALLBACK = "Use the current workflow context."
|
||||
_WORKFLOW_JOB_PROMPT_FALLBACK = "Use the workflow user prompt for this run."
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -173,13 +147,15 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
)
|
||||
|
||||
metadata = self._build_metadata(context, agent_soul, node_job)
|
||||
effective_node_job = node_job.model_copy(
|
||||
update={"previous_node_output_refs": self._effective_previous_node_output_refs(node_job)}
|
||||
workflow_context_prompt = self._build_workflow_context_prompt(context, node_job)
|
||||
# ENG-616: expand slash-menu mention tokens into model-readable names.
|
||||
# node_output mentions expand to their reference name only — the value
|
||||
# stays in the Workflow context block (user_prompt) below.
|
||||
workflow_job_prompt = (
|
||||
expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
|
||||
or "Run this workflow Agent Node for the current run."
|
||||
)
|
||||
workflow_task_prompt = self._build_workflow_task_prompt(context, effective_node_job)
|
||||
workflow_context_prompt = self._build_workflow_context_prompt(context, effective_node_job)
|
||||
workflow_job_prompt = workflow_task_prompt or self._WORKFLOW_JOB_PROMPT_FALLBACK
|
||||
user_prompt = workflow_context_prompt or self._WORKFLOW_USER_PROMPT_FALLBACK
|
||||
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
|
||||
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
|
||||
try:
|
||||
tools_layer = self._plugin_tools_builder.build(
|
||||
@@ -334,19 +310,15 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
context: WorkflowAgentRuntimeBuildContext,
|
||||
node_job: WorkflowNodeJobConfig,
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
lines = ["Workflow context loaded for this run:"]
|
||||
query = get_system_text(context.variable_pool, SystemVariableKey.QUERY)
|
||||
if query:
|
||||
lines.append(f"- User query: {query}")
|
||||
|
||||
resolved_outputs = self._resolve_previous_node_outputs(
|
||||
context.variable_pool,
|
||||
node_job.previous_node_output_refs,
|
||||
)
|
||||
if not query and not resolved_outputs:
|
||||
return ""
|
||||
|
||||
lines.append("Workflow context loaded for this run:")
|
||||
if query:
|
||||
lines.append(f"- User query: {query}")
|
||||
|
||||
if resolved_outputs:
|
||||
lines.append("- Previous node outputs:")
|
||||
for item in resolved_outputs:
|
||||
@@ -355,31 +327,6 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _build_workflow_task_prompt(
|
||||
self,
|
||||
context: WorkflowAgentRuntimeBuildContext,
|
||||
node_job: WorkflowNodeJobConfig,
|
||||
) -> str:
|
||||
del context
|
||||
return expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
|
||||
|
||||
def _effective_previous_node_output_refs(
|
||||
self,
|
||||
node_job: WorkflowNodeJobConfig,
|
||||
) -> list[WorkflowPreviousNodeOutputRef]:
|
||||
"""Derive effective refs from the current frontend task markers.
|
||||
|
||||
The task text is the source of truth for previous-node context. When
|
||||
the prompt has no frontend markers, stale persisted refs are discarded
|
||||
instead of being carried forward into workflow context.
|
||||
"""
|
||||
if not node_job.workflow_prompt:
|
||||
return list(node_job.previous_node_output_refs)
|
||||
|
||||
return workflow_previous_node_output_refs_from_selectors(
|
||||
extract_workflow_node_output_selectors(node_job.workflow_prompt)
|
||||
)
|
||||
|
||||
def _resolve_previous_node_outputs(
|
||||
self,
|
||||
variable_pool: VariablePoolReader,
|
||||
@@ -387,7 +334,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
) -> list[dict[str, Any]]:
|
||||
resolved: list[dict[str, Any]] = []
|
||||
for ref in refs:
|
||||
selector = normalize_previous_node_output_selector(ref)
|
||||
selector = self._selector_from_ref(ref)
|
||||
if not selector:
|
||||
raise WorkflowAgentRuntimeRequestBuildError(
|
||||
"invalid_previous_node_output_ref",
|
||||
@@ -409,72 +356,24 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def _summarize_value(value: Any) -> str:
|
||||
prompt_payload, used_download_mapping = WorkflowAgentRuntimeRequestBuilder._resolve_prompt_payload_value(value)
|
||||
if used_download_mapping:
|
||||
return json.dumps(prompt_payload, ensure_ascii=False, separators=(",", ":"))
|
||||
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> list[str] | None:
|
||||
for key in ("selector", "variable_selector", "value_selector"):
|
||||
value = ref.get(key)
|
||||
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||
return value
|
||||
node_id = ref.get("node_id")
|
||||
output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key")
|
||||
if isinstance(node_id, str) and isinstance(output_name, str):
|
||||
return [node_id, output_name]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _summarize_value(value: Any) -> str:
|
||||
text = str(value)
|
||||
if len(text) > 2000:
|
||||
return text[:2000] + "...[truncated]"
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _resolve_prompt_payload_value(cls, value: Any) -> tuple[Any, bool]:
|
||||
# File-valued workflow context must surface as Agent Stub download
|
||||
# mappings so the model can materialize those inputs with
|
||||
# `dify-agent file download --mapping ...` inside the sandbox.
|
||||
download_mapping = cls._agent_stub_download_mapping(value)
|
||||
if download_mapping is not None:
|
||||
return download_mapping, True
|
||||
|
||||
if isinstance(value, list | tuple):
|
||||
changed = False
|
||||
resolved_items: list[Any] = []
|
||||
for item in value:
|
||||
resolved_item, item_changed = cls._resolve_prompt_payload_value(item)
|
||||
resolved_items.append(resolved_item)
|
||||
changed = changed or item_changed
|
||||
return resolved_items, changed
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
changed = False
|
||||
resolved_items_by_key: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
resolved_item, item_changed = cls._resolve_prompt_payload_value(item)
|
||||
resolved_items_by_key[str(key)] = resolved_item
|
||||
changed = changed or item_changed
|
||||
return resolved_items_by_key, changed
|
||||
|
||||
return value, False
|
||||
|
||||
@staticmethod
|
||||
def _agent_stub_download_mapping(value: Any) -> dict[str, Any] | None:
|
||||
try:
|
||||
if isinstance(value, File):
|
||||
transfer_method = _AGENT_STUB_FILE_TRANSFER_METHODS.get(value.transfer_method)
|
||||
if transfer_method is None:
|
||||
return None
|
||||
if value.transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
url = value.remote_url
|
||||
if not isinstance(url, str) or not url:
|
||||
return None
|
||||
mapping = AgentStubFileMapping(transfer_method=transfer_method, url=url)
|
||||
else:
|
||||
reference = value.reference
|
||||
if not isinstance(reference, str) or not reference:
|
||||
return None
|
||||
mapping = AgentStubFileMapping(transfer_method=transfer_method, reference=reference)
|
||||
return mapping.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
mapping = AgentStubFileMapping.model_validate(value)
|
||||
return mapping.model_dump(mode="json", exclude_none=True)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_output_config(declared_outputs: Sequence[DeclaredOutputConfig]) -> AgentBackendOutputConfig | None:
|
||||
"""Build the structured-output layer config sent to Agent backend.
|
||||
@@ -648,84 +547,42 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
|
||||
|
||||
|
||||
def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None:
|
||||
"""Map Agent Soul knowledge sets into one Dify knowledge-base layer.
|
||||
"""Map Agent Soul knowledge config into the fixed Dify knowledge-base layer.
|
||||
|
||||
Agent Soul DTO validation owns malformed set rejection. Runtime mapping is
|
||||
intentionally lossless: every configured set is forwarded with its query
|
||||
policy, dataset refs, retrieval controls, and metadata-filtering controls.
|
||||
``score_threshold=None`` means disabled threshold filtering and maps to the
|
||||
inner retrieval request's ``0.0`` default through the Agent backend DTO.
|
||||
Normalization intentionally matches the current dify-agent runtime contract:
|
||||
|
||||
- blank or missing dataset ids are ignored;
|
||||
- if no valid dataset ids remain, no knowledge layer is injected;
|
||||
- retrieval mode is always forced to ``multiple`` in this first wiring pass;
|
||||
- ``top_k`` falls back to a stable runtime default when the soul omits it;
|
||||
- ``score_threshold`` is only forwarded when the product config explicitly
|
||||
enables it, otherwise the layer keeps the disabled/default ``0.0`` value;
|
||||
- metadata filtering stays at the layer DTO default (disabled).
|
||||
"""
|
||||
if not agent_soul.knowledge.sets:
|
||||
dataset_ids = list_configured_knowledge_dataset_ids(agent_soul)
|
||||
if not dataset_ids:
|
||||
return None
|
||||
|
||||
query_config = agent_soul.knowledge.query_config
|
||||
return DifyKnowledgeBaseLayerConfig(
|
||||
sets=[
|
||||
DifyKnowledgeSetConfig(
|
||||
id=knowledge_set.id,
|
||||
name=knowledge_set.name,
|
||||
description=knowledge_set.description,
|
||||
datasets=[
|
||||
DifyKnowledgeDatasetConfig(
|
||||
id=dataset.id or "",
|
||||
name=dataset.name,
|
||||
description=dataset.description,
|
||||
)
|
||||
for dataset in knowledge_set.datasets
|
||||
],
|
||||
query=DifyKnowledgeQueryConfig(
|
||||
mode=cast(Literal["user_query", "generated_query"], knowledge_set.query.mode.value),
|
||||
value=knowledge_set.query.value,
|
||||
),
|
||||
retrieval=_knowledge_retrieval_config(knowledge_set.retrieval),
|
||||
metadata_filtering=_knowledge_metadata_filtering_config(knowledge_set.metadata_filtering),
|
||||
)
|
||||
for knowledge_set in agent_soul.knowledge.sets
|
||||
],
|
||||
dataset_ids=dataset_ids,
|
||||
retrieval=DifyKnowledgeRetrievalConfig(
|
||||
mode="multiple",
|
||||
top_k=_knowledge_top_k(query_config),
|
||||
score_threshold=_knowledge_score_threshold(query_config),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> DifyKnowledgeRetrievalConfig:
|
||||
return DifyKnowledgeRetrievalConfig(
|
||||
mode=retrieval.mode,
|
||||
top_k=retrieval.top_k,
|
||||
score_threshold=retrieval.score_threshold or 0.0,
|
||||
reranking_mode=retrieval.reranking_mode,
|
||||
reranking_enable=retrieval.reranking_enable,
|
||||
reranking_model=DifyKnowledgeRerankingModelConfig(
|
||||
provider=retrieval.reranking_model.provider,
|
||||
model=retrieval.reranking_model.model,
|
||||
)
|
||||
if retrieval.reranking_model is not None
|
||||
else None,
|
||||
weights=cast(dict[str, Any], retrieval.weights.model_dump(mode="json", exclude_none=True))
|
||||
if retrieval.weights is not None
|
||||
else None,
|
||||
model=_knowledge_model_config(retrieval.model),
|
||||
)
|
||||
def _knowledge_top_k(query_config: AgentKnowledgeQueryConfig) -> int:
|
||||
top_k = query_config.top_k
|
||||
return top_k if isinstance(top_k, int) and top_k >= 1 else 4
|
||||
|
||||
|
||||
def _knowledge_metadata_filtering_config(
|
||||
metadata_filtering: AgentKnowledgeMetadataFilteringConfig,
|
||||
) -> DifyKnowledgeMetadataFilteringConfig:
|
||||
return DifyKnowledgeMetadataFilteringConfig(
|
||||
mode=metadata_filtering.mode,
|
||||
model_config=_knowledge_model_config(metadata_filtering.metadata_model_config),
|
||||
conditions=cast(Any, metadata_filtering.conditions.model_dump(mode="json"))
|
||||
if metadata_filtering.conditions is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
def _knowledge_model_config(model: AgentKnowledgeModelConfig | None) -> DifyKnowledgeModelConfig | None:
|
||||
if model is None:
|
||||
return None
|
||||
return DifyKnowledgeModelConfig(
|
||||
provider=model.provider,
|
||||
name=model.name,
|
||||
mode=model.mode,
|
||||
completion_params=model.completion_params,
|
||||
)
|
||||
def _knowledge_score_threshold(query_config: AgentKnowledgeQueryConfig) -> float:
|
||||
if query_config.score_threshold_enabled and query_config.score_threshold is not None:
|
||||
return query_config.score_threshold
|
||||
return 0.0
|
||||
|
||||
|
||||
def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None:
|
||||
|
||||
@@ -18,7 +18,6 @@ from models.agent_config_entities import (
|
||||
)
|
||||
from models.model import UploadFile
|
||||
from models.workflow import Workflow
|
||||
from services.agent.knowledge_datasets import list_missing_tenant_knowledge_dataset_ids
|
||||
|
||||
from .entities import DifyAgentNodeData
|
||||
|
||||
@@ -147,7 +146,6 @@ class WorkflowAgentNodeValidator:
|
||||
)
|
||||
cls._validate_agent_soul_env(binding=binding, agent_soul=agent_soul)
|
||||
cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul)
|
||||
cls._validate_agent_soul_knowledge(binding=binding, agent_soul=agent_soul)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
|
||||
|
||||
@@ -366,24 +364,6 @@ class WorkflowAgentNodeValidator:
|
||||
)
|
||||
cli_tool_names.add(normalized_name)
|
||||
|
||||
@classmethod
|
||||
def _validate_agent_soul_knowledge(
|
||||
cls,
|
||||
*,
|
||||
binding: WorkflowAgentNodeBinding,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> None:
|
||||
"""Validate knowledge set dataset rows against the publishing tenant."""
|
||||
missing_ids = list_missing_tenant_knowledge_dataset_ids(
|
||||
tenant_id=binding.tenant_id,
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
if missing_ids:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} references missing or out-of-scope knowledge datasets: "
|
||||
f"{', '.join(missing_ids)}."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_agent_soul_env(
|
||||
cls,
|
||||
|
||||
@@ -6,7 +6,6 @@ from pydantic import Field, field_validator
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import to_timestamp
|
||||
from models.agent import (
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentIconType,
|
||||
AgentKind,
|
||||
@@ -48,18 +47,6 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel):
|
||||
created_at: int | None = None
|
||||
|
||||
|
||||
class AgentConfigDraftSummaryResponse(ResponseModel):
|
||||
id: str
|
||||
agent_id: str
|
||||
draft_type: AgentConfigDraftType
|
||||
account_id: str | None = None
|
||||
base_snapshot_id: str | None = None
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: int | None = None
|
||||
updated_at: int | None = None
|
||||
|
||||
|
||||
class AgentPublishedReferenceResponse(ResponseModel):
|
||||
app_id: str
|
||||
app_name: str
|
||||
@@ -85,8 +72,6 @@ class AgentRosterResponse(ResponseModel):
|
||||
scope: AgentScope
|
||||
source: AgentSource
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
workflow_id: str | None = None
|
||||
workflow_node_id: str | None = None
|
||||
active_config_snapshot_id: str | None = None
|
||||
@@ -307,24 +292,14 @@ class AgentConfigSnapshotListResponse(ResponseModel):
|
||||
class AgentConfigSnapshotRestoreResponse(ResponseModel):
|
||||
result: Literal["success"]
|
||||
active_config_snapshot_id: str
|
||||
draft_config_id: str | None = None
|
||||
restored_version_id: str | None = None
|
||||
|
||||
|
||||
class AgentComposerAgentResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
role: str | None = None
|
||||
icon_type: str | None = None
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
scope: AgentScope
|
||||
source: AgentSource | None = None
|
||||
status: AgentStatus
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
active_config_snapshot_id: str | None = None
|
||||
|
||||
|
||||
@@ -368,9 +343,6 @@ class WorkflowAgentComposerResponse(ResponseModel):
|
||||
impact_summary: AgentComposerImpactResponse | None = None
|
||||
validation: "ComposerValidationFindingsResponse | None" = None
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
chat_endpoint: str | None = None
|
||||
workflow_id: str | None = None
|
||||
node_id: str | None = None
|
||||
|
||||
@@ -378,15 +350,10 @@ class WorkflowAgentComposerResponse(ResponseModel):
|
||||
class AgentAppComposerResponse(ResponseModel):
|
||||
variant: Literal[ComposerVariant.AGENT_APP]
|
||||
agent: AgentComposerAgentResponse
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
|
||||
draft: AgentConfigDraftSummaryResponse | None = None
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse
|
||||
agent_soul: AgentSoulConfig
|
||||
save_options: list[ComposerSaveStrategy]
|
||||
validation: "ComposerValidationFindingsResponse | None" = None
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
chat_endpoint: str | None = None
|
||||
|
||||
|
||||
class ComposerValidationWarningResponse(ResponseModel):
|
||||
@@ -433,22 +400,10 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
|
||||
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerKnowledgeDatasetCandidateResponse(AgentKnowledgeDatasetConfig):
|
||||
missing: bool = False
|
||||
|
||||
|
||||
class AgentComposerKnowledgeSetCandidateResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
datasets: list[AgentComposerKnowledgeDatasetCandidateResponse] = Field(default_factory=list)
|
||||
missing_dataset_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerSoulCandidatesResponse(ResponseModel):
|
||||
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
|
||||
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
|
||||
knowledge_sets: list[AgentComposerKnowledgeSetCandidateResponse] = Field(default_factory=list)
|
||||
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
|
||||
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
+26
-10
@@ -236,6 +236,16 @@ class TokenExpiredError(Exception):
|
||||
"""Hard-expire bookkeeping is the resolver's job before raising."""
|
||||
|
||||
|
||||
class NegativeCache(StrEnum):
|
||||
"""Negative cache markers. ``EXPIRED`` is distinct from ``INVALID`` so a
|
||||
retry inside ``NEGATIVE_TTL`` still reports expiry instead of collapsing
|
||||
into a generic unknown-token miss.
|
||||
"""
|
||||
|
||||
INVALID = "invalid"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Registry
|
||||
# ============================================================================
|
||||
@@ -343,13 +353,15 @@ class OAuthAccessTokenResolver:
|
||||
def _cache_key(self, token_hash: str) -> str:
|
||||
return TOKEN_CACHE_KEY_FMT.format(hash=token_hash)
|
||||
|
||||
def cache_get(self, token_hash: str) -> ResolvedRow | None | Literal["invalid"]:
|
||||
def cache_get(self, token_hash: str) -> ResolvedRow | None | NegativeCache:
|
||||
raw = self._redis.get(self._cache_key(token_hash))
|
||||
if raw is None:
|
||||
return None
|
||||
text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw
|
||||
if text == "invalid":
|
||||
return "invalid"
|
||||
try:
|
||||
return NegativeCache(text)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return ResolvedRow.from_cache(json.loads(text))
|
||||
except (ValueError, KeyError):
|
||||
@@ -363,8 +375,8 @@ class OAuthAccessTokenResolver:
|
||||
json.dumps(row.to_cache()),
|
||||
)
|
||||
|
||||
def cache_set_negative(self, token_hash: str) -> None:
|
||||
self._redis.setex(self._cache_key(token_hash), self._negative_ttl, "invalid")
|
||||
def cache_set_negative(self, token_hash: str, marker: NegativeCache = NegativeCache.INVALID) -> None:
|
||||
self._redis.setex(self._cache_key(token_hash), self._negative_ttl, str(marker))
|
||||
|
||||
def hard_expire(self, session: Session, row_id: uuid.UUID | str, token_hash: str) -> None:
|
||||
"""Atomic CAS — only the worker that flips revoked_at emits audit;
|
||||
@@ -385,7 +397,7 @@ class OAuthAccessTokenResolver:
|
||||
extra={"audit": True, "token_id": str(row_id)},
|
||||
)
|
||||
self._redis.delete(self._cache_key(token_hash))
|
||||
self.cache_set_negative(token_hash)
|
||||
self.cache_set_negative(token_hash, NegativeCache.EXPIRED)
|
||||
|
||||
|
||||
class _VariantResolver:
|
||||
@@ -395,9 +407,11 @@ class _VariantResolver:
|
||||
|
||||
def resolve(self, token_hash: str) -> ResolvedRow | None:
|
||||
cached = self._parent.cache_get(token_hash)
|
||||
if cached == "invalid":
|
||||
if isinstance(cached, NegativeCache):
|
||||
if cached is NegativeCache.EXPIRED:
|
||||
raise TokenExpiredError("token_expired")
|
||||
return None
|
||||
if cached is not None and not isinstance(cached, str):
|
||||
if cached is not None:
|
||||
if not self._matches_variant(cached):
|
||||
return None
|
||||
return cached
|
||||
@@ -413,7 +427,7 @@ class _VariantResolver:
|
||||
now = datetime.now(UTC)
|
||||
if row.expires_at is not None and row.expires_at <= now:
|
||||
self._parent.hard_expire(session, row.id, token_hash)
|
||||
return None
|
||||
raise TokenExpiredError("token_expired")
|
||||
|
||||
if not self._matches_variant_model(row):
|
||||
logger.error(
|
||||
@@ -472,7 +486,7 @@ def record_layer0_verdict(token_hash: str, tenant_id: str, verdict: bool) -> Non
|
||||
if raw is None:
|
||||
return
|
||||
text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw
|
||||
if text == "invalid":
|
||||
if text in (NegativeCache.INVALID, NegativeCache.EXPIRED):
|
||||
return
|
||||
try:
|
||||
data = json.loads(text)
|
||||
@@ -601,6 +615,8 @@ def validate_bearer(*, accept: frozenset[Accepts]) -> Callable[[Callable[_DP, _D
|
||||
|
||||
try:
|
||||
ctx = get_authenticator().authenticate(token)
|
||||
except TokenExpiredError:
|
||||
raise Unauthorized("token_expired")
|
||||
except InvalidBearerError as e:
|
||||
raise Unauthorized(str(e))
|
||||
|
||||
|
||||
+13
@@ -1,5 +1,16 @@
|
||||
"""add workflow_version to workflow_agent_node_bindings
|
||||
|
||||
Restores the stage 1 §5.3 unique key
|
||||
``(tenant_id, workflow_id, workflow_version, node_id)`` so draft and published
|
||||
workflow bindings can coexist at the same workflow_id once we want to track
|
||||
them per workflow version. ``workflow_version`` mirrors ``workflows.version``
|
||||
("draft" or a published version string).
|
||||
|
||||
Because the New Agent Experience feature is pre-release, this table is empty
|
||||
in every environment that matters; the ``server_default='draft'`` only exists
|
||||
to keep developer-local rows valid during the alter and is dropped immediately
|
||||
afterward so application code must specify ``workflow_version`` explicitly.
|
||||
|
||||
Revision ID: 97e2e1a644e8
|
||||
Revises: f8b6b7e9c421
|
||||
Create Date: 2026-05-25 11:43:37.611300
|
||||
@@ -22,8 +33,10 @@ def upgrade():
|
||||
'workflow_version',
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
server_default='draft',
|
||||
)
|
||||
)
|
||||
batch_op.alter_column('workflow_version', server_default=None)
|
||||
batch_op.drop_constraint(
|
||||
batch_op.f('workflow_agent_node_binding_node_unique'), type_='unique'
|
||||
)
|
||||
|
||||
@@ -18,7 +18,8 @@ depends_on = None
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False))
|
||||
batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False, server_default=""))
|
||||
batch_op.alter_column("role", server_default=None)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
+44
-1
@@ -6,9 +6,15 @@ Create Date: 2026-06-18 23:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.engine.mock import MockConnection
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b2515f9d4c2a"
|
||||
@@ -31,9 +37,46 @@ def upgrade() -> None:
|
||||
"agent_drive_files",
|
||||
["tenant_id", "agent_id", "is_skill", "key"],
|
||||
)
|
||||
_remove_skills_files_from_snapshots()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files")
|
||||
op.drop_column("agent_drive_files", "skill_metadata")
|
||||
op.drop_column("agent_drive_files", "is_skill")
|
||||
|
||||
|
||||
def _remove_skills_files_from_snapshots() -> None:
|
||||
connection = op.get_bind()
|
||||
if connection is None or isinstance(connection, MockConnection):
|
||||
return
|
||||
snapshots = sa.table(
|
||||
"agent_config_snapshots",
|
||||
sa.column("id", sa.String()),
|
||||
sa.column("config_snapshot", sa.Text()),
|
||||
)
|
||||
rows = connection.execute(sa.select(snapshots.c.id, snapshots.c.config_snapshot)).fetchall()
|
||||
for row in rows:
|
||||
cleaned = _strip_skills_files(row.config_snapshot)
|
||||
if cleaned is None:
|
||||
continue
|
||||
connection.execute(
|
||||
snapshots.update()
|
||||
.where(snapshots.c.id == row.id)
|
||||
.values(config_snapshot=json.dumps(cleaned, separators=(",", ":"), sort_keys=True))
|
||||
)
|
||||
|
||||
|
||||
def _strip_skills_files(raw_snapshot: Any) -> dict[str, Any] | None:
|
||||
if raw_snapshot is None:
|
||||
return None
|
||||
if isinstance(raw_snapshot, str):
|
||||
snapshot = json.loads(raw_snapshot)
|
||||
elif isinstance(raw_snapshot, dict):
|
||||
snapshot = dict(raw_snapshot)
|
||||
else:
|
||||
snapshot = dict(raw_snapshot)
|
||||
if not isinstance(snapshot, dict) or "skills_files" not in snapshot:
|
||||
return None
|
||||
snapshot.pop("skills_files", None)
|
||||
return snapshot
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
"""add input placeholder to sites
|
||||
|
||||
Revision ID: a6f1c9d2e8b4
|
||||
Revises: d9e8f7a6b5c4
|
||||
Create Date: 2026-06-24 19:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a6f1c9d2e8b4"
|
||||
down_revision = "d9e8f7a6b5c4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("sites", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("input_placeholder", sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("sites", schema=None) as batch_op:
|
||||
batch_op.drop_column("input_placeholder")
|
||||
@@ -1,56 +0,0 @@
|
||||
"""add agent config drafts
|
||||
|
||||
Revision ID: e4f5a6b7c8d9
|
||||
Revises: a6f1c9d2e8b4
|
||||
Create Date: 2026-06-24 20:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e4f5a6b7c8d9"
|
||||
down_revision = "a6f1c9d2e8b4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"agent_config_drafts",
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("draft_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("account_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("draft_owner_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("base_snapshot_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("config_snapshot", models.types.LongText(), nullable=False),
|
||||
sa.Column("created_by", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("updated_by", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("agent_config_draft_pkey")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"agent_id",
|
||||
"draft_type",
|
||||
"draft_owner_key",
|
||||
name=op.f("agent_config_draft_agent_type_account_unique"),
|
||||
),
|
||||
)
|
||||
op.create_index("agent_config_draft_tenant_agent_idx", "agent_config_drafts", ["tenant_id", "agent_id"])
|
||||
op.create_index(
|
||||
"agent_config_draft_base_snapshot_idx",
|
||||
"agent_config_drafts",
|
||||
["tenant_id", "base_snapshot_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("agent_config_draft_base_snapshot_idx", table_name="agent_config_drafts")
|
||||
op.drop_index("agent_config_draft_tenant_agent_idx", table_name="agent_config_drafts")
|
||||
op.drop_table("agent_config_drafts")
|
||||
@@ -1,30 +0,0 @@
|
||||
"""add agent backing app id
|
||||
|
||||
Revision ID: a2b3c4d5e6f7
|
||||
Revises: e4f5a6b7c8d9
|
||||
Create Date: 2026-06-25 11:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a2b3c4d5e6f7"
|
||||
down_revision = "e4f5a6b7c8d9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("backing_app_id", models.types.StringUUID(), nullable=True))
|
||||
op.create_index("agent_tenant_backing_app_id_idx", "agents", ["tenant_id", "backing_app_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("agent_tenant_backing_app_id_idx", table_name="agents")
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.drop_column("backing_app_id")
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
"""add agent active config is published
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: a2b3c4d5e6f7
|
||||
Create Date: 2026-06-26 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c3d4e5f6a7b8"
|
||||
down_revision = "a2b3c4d5e6f7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"active_config_is_published",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("false"),
|
||||
nullable=False,
|
||||
comment=(
|
||||
"Whether the normal shared Agent draft has been published into the active config snapshot. "
|
||||
"User-scoped debug drafts do not affect this flag."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.drop_column("active_config_is_published")
|
||||
@@ -10,8 +10,6 @@ from .account import (
|
||||
)
|
||||
from .agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -156,8 +154,6 @@ __all__ = [
|
||||
"AccountStatus",
|
||||
"AccountTrialAppRecord",
|
||||
"Agent",
|
||||
"AgentConfigDraft",
|
||||
"AgentConfigDraftType",
|
||||
"AgentConfigRevision",
|
||||
"AgentConfigRevisionOperation",
|
||||
"AgentConfigSnapshot",
|
||||
|
||||
+3
-71
@@ -85,17 +85,6 @@ class AgentConfigRevisionOperation(StrEnum):
|
||||
SAVE_TO_ROSTER = "save_to_roster"
|
||||
# Switches the Agent's current published config back to an existing version.
|
||||
RESTORE_VERSION = "restore_version"
|
||||
# Publishes the editable Agent Soul draft as a new immutable version.
|
||||
PUBLISH_DRAFT = "publish_draft"
|
||||
|
||||
|
||||
class AgentConfigDraftType(StrEnum):
|
||||
"""Editable Agent Soul draft workspace type."""
|
||||
|
||||
# Shared Agent Console draft edited by users before publishing.
|
||||
DRAFT = "draft"
|
||||
# Per-editor build draft mutated during debug/build mode.
|
||||
DEBUG_BUILD = "debug_build"
|
||||
|
||||
|
||||
class WorkflowAgentBindingType(StrEnum):
|
||||
@@ -145,7 +134,6 @@ class Agent(DefaultFieldsMixin, Base):
|
||||
Index("agent_tenant_scope_idx", "tenant_id", "scope"),
|
||||
Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"),
|
||||
Index("agent_tenant_app_id_idx", "tenant_id", "app_id"),
|
||||
Index("agent_tenant_backing_app_id_idx", "tenant_id", "backing_app_id"),
|
||||
Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"),
|
||||
Index(
|
||||
"agent_tenant_invitable_idx",
|
||||
@@ -174,30 +162,12 @@ class Agent(DefaultFieldsMixin, Base):
|
||||
scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False)
|
||||
source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False)
|
||||
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
backing_app_id: Mapped[str | None] = mapped_column(
|
||||
StringUUID,
|
||||
nullable=True,
|
||||
comment=(
|
||||
"Runtime Agent App used for chat/log/monitoring. For workflow-only agents, "
|
||||
"app_id remains the parent workflow app id and this points to the hidden backing app."
|
||||
),
|
||||
)
|
||||
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
active_config_has_model: Mapped[bool] = mapped_column(
|
||||
sa.Boolean, nullable=False, default=False, server_default=sa.text("false")
|
||||
)
|
||||
active_config_is_published: Mapped[bool] = mapped_column(
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.text("false"),
|
||||
comment=(
|
||||
"Whether the normal shared Agent draft has been published into the active config snapshot. "
|
||||
"User-scoped debug drafts do not affect this flag."
|
||||
),
|
||||
)
|
||||
status: Mapped[AgentStatus] = mapped_column(
|
||||
EnumText(AgentStatus, length=32), nullable=False, default=AgentStatus.ACTIVE
|
||||
)
|
||||
@@ -240,44 +210,6 @@ class AgentDebugConversation(DefaultFieldsMixin, Base):
|
||||
conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
|
||||
|
||||
class AgentConfigDraft(DefaultFieldsMixin, Base):
|
||||
"""Editable Agent Soul draft separated from immutable published snapshots."""
|
||||
|
||||
__tablename__ = "agent_config_drafts"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="agent_config_draft_pkey"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"agent_id",
|
||||
"draft_type",
|
||||
"draft_owner_key",
|
||||
name="agent_config_draft_agent_type_account_unique",
|
||||
),
|
||||
Index("agent_config_draft_tenant_agent_idx", "tenant_id", "agent_id"),
|
||||
Index("agent_config_draft_base_snapshot_idx", "tenant_id", "base_snapshot_id"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
draft_type: Mapped[AgentConfigDraftType] = mapped_column(EnumText(AgentConfigDraftType, length=32), nullable=False)
|
||||
account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
@property
|
||||
def config_snapshot_dict(self) -> dict[str, Any]:
|
||||
if not self.config_snapshot:
|
||||
return {}
|
||||
if hasattr(self.config_snapshot, "model_dump"):
|
||||
return self.config_snapshot.model_dump(mode="json")
|
||||
if isinstance(self.config_snapshot, str):
|
||||
return json.loads(self.config_snapshot)
|
||||
return dict(self.config_snapshot)
|
||||
|
||||
|
||||
class AgentConfigSnapshot(DefaultFieldsMixin, Base):
|
||||
"""Immutable Agent Soul snapshot.
|
||||
|
||||
@@ -423,9 +355,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base):
|
||||
agent_config_snapshot_id / composition_layer_specs`` columns are set.
|
||||
- Agent App conversations: ``owner_type = conversation``; the
|
||||
``conversation_id`` column is set and the workflow columns stay NULL.
|
||||
Runtime state is scoped by ``agent_config_snapshot_id``. For published
|
||||
web/API runs this points to an immutable AgentConfigSnapshot; for console
|
||||
debugger/build runs it points to the editable AgentConfigDraft row.
|
||||
Published/web/API runs scope runtime state by ``agent_config_snapshot_id``;
|
||||
console debugger runs may keep it NULL so prompt-only draft saves can reuse
|
||||
the same preview conversation state while executing the latest Agent Soul.
|
||||
|
||||
The snapshot is runtime state returned by Agent backend, kept separate from
|
||||
Agent Soul snapshots and workflow node-job config.
|
||||
|
||||
@@ -2,11 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Any, Final, Literal, Self
|
||||
from typing import Annotated, Any, Final, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator
|
||||
|
||||
from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator
|
||||
from core.workflow.file_reference import is_canonical_file_reference
|
||||
from graphon.file import FileTransferMethod
|
||||
|
||||
@@ -162,11 +161,6 @@ class AgentSkillRefConfig(AgentFlexibleConfig):
|
||||
manifest_files: list[str] | None = None
|
||||
|
||||
|
||||
class AgentSoulFilesConfig(BaseModel):
|
||||
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
|
||||
files: list[AgentFileRefConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentPermissionConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@@ -242,161 +236,17 @@ class AgentCliToolConfig(AgentFlexibleConfig):
|
||||
inferred_from: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AgentKnowledgeDatasetConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):
|
||||
id: str | None = Field(default=None, max_length=255)
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AgentKnowledgeQueryConfig(BaseModel):
|
||||
"""Per-set query policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
|
||||
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
|
||||
set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
``value`` while ``generated_query`` leaves that value empty.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: AgentKnowledgeQueryMode
|
||||
value: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query(self) -> Self:
|
||||
if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip():
|
||||
raise ValueError("knowledge query.value is required for user_query mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeModelConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
mode: str = Field(min_length=1, max_length=64)
|
||||
completion_params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentKnowledgeRerankingModelConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: str = Field(min_length=1, max_length=255)
|
||||
model: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class AgentKnowledgeWeightedScoreConfig(AgentFlexibleConfig):
|
||||
weight_type: str | None = Field(default=None, max_length=64)
|
||||
vector_setting: dict[str, Any] | None = None
|
||||
keyword_setting: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AgentKnowledgeRetrievalConfig(BaseModel):
|
||||
"""Per-set retrieval policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Retrieval settings now live on each knowledge set instead of one shared
|
||||
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
|
||||
``single`` retrieval with a required model config.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: Literal["single", "multiple"]
|
||||
class AgentKnowledgeQueryConfig(AgentFlexibleConfig):
|
||||
query: str | None = None
|
||||
top_k: int | None = Field(default=None, ge=1)
|
||||
score_threshold: float | None = Field(default=None, ge=0, le=1)
|
||||
reranking_mode: str = "reranking_model"
|
||||
reranking_enable: bool = True
|
||||
reranking_model: AgentKnowledgeRerankingModelConfig | None = None
|
||||
weights: AgentKnowledgeWeightedScoreConfig | None = None
|
||||
model: AgentKnowledgeModelConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_fields(self) -> Self:
|
||||
if self.mode == "multiple" and self.top_k is None:
|
||||
raise ValueError("knowledge retrieval.top_k is required for multiple mode")
|
||||
if self.mode == "single" and self.model is None:
|
||||
raise ValueError("knowledge retrieval.model is required for single mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataCondition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
comparison_operator: SupportedComparisonOperator
|
||||
value: ConditionValue = None
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataConditions(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
logical_operator: Literal["and", "or"] = "and"
|
||||
conditions: list[AgentKnowledgeMetadataCondition] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataFilteringConfig(BaseModel):
|
||||
"""Per-set metadata filtering policy.
|
||||
|
||||
The Python attribute uses ``metadata_model_config`` for clarity because the
|
||||
model belongs to metadata filtering specifically, while the external API and
|
||||
generated schema keep the historical ``model_config`` field name via alias.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
mode: Literal["disabled", "automatic", "manual"] = "disabled"
|
||||
# Internal name is explicit; wire format remains ``model_config``.
|
||||
metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config")
|
||||
conditions: AgentKnowledgeMetadataConditions | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_fields(self) -> Self:
|
||||
if self.mode == "automatic" and self.metadata_model_config is None:
|
||||
raise ValueError("metadata_filtering.model_config is required for automatic mode")
|
||||
if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions):
|
||||
raise ValueError("metadata_filtering.conditions is required for manual mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeSetConfig(BaseModel):
|
||||
"""One explicit knowledge set in Agent v2.
|
||||
|
||||
``knowledge.sets`` replaces the old flat knowledge config. Each set owns
|
||||
its datasets plus query, retrieval, and metadata policies. An individual
|
||||
set must contain at least one dataset id even though the overall knowledge
|
||||
section may be empty, which is how callers express "no knowledge layer".
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
datasets: list[AgentKnowledgeDatasetConfig]
|
||||
query: AgentKnowledgeQueryConfig
|
||||
retrieval: AgentKnowledgeRetrievalConfig
|
||||
metadata_filtering: AgentKnowledgeMetadataFilteringConfig = Field(
|
||||
default_factory=AgentKnowledgeMetadataFilteringConfig
|
||||
)
|
||||
|
||||
@field_validator("id", "name")
|
||||
@classmethod
|
||||
def validate_non_blank_identity(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("knowledge set id and name must not be blank")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_datasets(self) -> Self:
|
||||
dataset_ids = [(dataset.id or "").strip() for dataset in self.datasets]
|
||||
if not dataset_ids or any(not dataset_id for dataset_id in dataset_ids):
|
||||
raise ValueError("knowledge set requires at least one dataset id")
|
||||
if len(dataset_ids) != len(set(dataset_ids)):
|
||||
raise ValueError("knowledge set dataset ids must be unique")
|
||||
return self
|
||||
score_threshold_enabled: bool | None = None
|
||||
|
||||
|
||||
class AgentHumanContactConfig(AgentFlexibleConfig):
|
||||
@@ -603,28 +453,9 @@ class AgentSoulToolsConfig(BaseModel):
|
||||
|
||||
|
||||
class AgentSoulKnowledgeConfig(BaseModel):
|
||||
"""Top-level Agent v2 knowledge config.
|
||||
|
||||
Agent v2 models knowledge as explicit sets instead of one flat
|
||||
``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
|
||||
list means no knowledge layer should be emitted at runtime, while set-name
|
||||
uniqueness stays case-insensitive because runtime selection addresses sets
|
||||
by name.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sets: list[AgentKnowledgeSetConfig] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_sets(self) -> Self:
|
||||
set_ids = [item.id.strip() for item in self.sets]
|
||||
if len(set_ids) != len(set(set_ids)):
|
||||
raise ValueError("knowledge set ids must be unique")
|
||||
set_names = [item.name.strip().lower() for item in self.sets]
|
||||
if len(set_names) != len(set(set_names)):
|
||||
raise ValueError("knowledge set names must be unique")
|
||||
return self
|
||||
datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
|
||||
query_mode: AgentKnowledgeQueryMode | None = None
|
||||
query_config: AgentKnowledgeQueryConfig = Field(default_factory=AgentKnowledgeQueryConfig)
|
||||
|
||||
|
||||
class AgentSoulHumanConfig(BaseModel):
|
||||
@@ -682,7 +513,6 @@ class AgentSoulConfig(BaseModel):
|
||||
knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig)
|
||||
human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig)
|
||||
env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig)
|
||||
files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
|
||||
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
|
||||
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
|
||||
model: AgentSoulModelConfig | None = None
|
||||
|
||||
+3
-10
@@ -487,15 +487,9 @@ class App(Base):
|
||||
|
||||
agent = db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == self.tenant_id,
|
||||
sa.or_(
|
||||
sa.and_(
|
||||
Agent.app_id == self.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
),
|
||||
Agent.backing_app_id == self.id,
|
||||
),
|
||||
Agent.app_id == self.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
@@ -2189,7 +2183,6 @@ class Site(Base):
|
||||
chat_color_theme_inverted: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
|
||||
copyright = mapped_column(String(255))
|
||||
privacy_policy = mapped_column(String(255))
|
||||
input_placeholder = mapped_column(String(255))
|
||||
show_workflow_steps: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"))
|
||||
use_icon_as_answer_icon: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
|
||||
_custom_disclaimer: Mapped[str] = mapped_column("custom_disclaimer", LongText, default="")
|
||||
|
||||
@@ -465,83 +465,6 @@ Check if activation token is valid
|
||||
| ---- | ----------- |
|
||||
| 204 | Agent service API key deleted |
|
||||
|
||||
### [DELETE] /agent/{agent_id}/build-draft
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent build draft discarded | **application/json**: [AgentSimpleResultResponse](#agentsimpleresultresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/build-draft
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent build draft | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
|
||||
|
||||
### [PUT] /agent/{agent_id}/build-draft
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent build draft saved | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
|
||||
|
||||
### [POST] /agent/{agent_id}/build-draft/apply
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent build draft applied | **application/json**: [AgentBuildDraftApplyResponse](#agentbuilddraftapplyresponse)<br> |
|
||||
|
||||
### [POST] /agent/{agent_id}/build-draft/checkout
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [AgentBuildDraftCheckoutPayload](#agentbuilddraftcheckoutpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent build draft checked out | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/chat-messages
|
||||
Get Agent App chat messages for a conversation with pagination
|
||||
|
||||
@@ -933,26 +856,6 @@ Get Agent App message details by ID
|
||||
| 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)<br> |
|
||||
| 404 | Agent or message not found | |
|
||||
|
||||
### [POST] /agent/{agent_id}/publish
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [AgentPublishPayload](#agentpublishpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent draft published | **application/json**: [AgentPublishResponse](#agentpublishresponse)<br> |
|
||||
| 403 | Insufficient permissions | |
|
||||
|
||||
### [GET] /agent/{agent_id}/referencing-workflows
|
||||
List workflow apps that reference this Agent App's bound Agent (read-only)
|
||||
|
||||
@@ -3864,7 +3767,6 @@ Submit human input form preview for workflow
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| snapshot_id | query | | No | string |
|
||||
| app_id | path | | Yes | string (uuid) |
|
||||
| node_id | path | | Yes | string |
|
||||
|
||||
@@ -12271,14 +12173,9 @@ Default namespace
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
|
||||
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes |
|
||||
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
|
||||
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
|
||||
| app_id | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| chat_endpoint | string | | No |
|
||||
| draft | [AgentConfigDraftSummaryResponse](#agentconfigdraftsummaryresponse) | | No |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
|
||||
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
|
||||
| variant | string | | Yes |
|
||||
@@ -12313,7 +12210,6 @@ Default namespace
|
||||
| active_config_is_published | boolean | | No |
|
||||
| api_base_url | string | | No |
|
||||
| app_id | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| bound_agent_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
@@ -12322,7 +12218,6 @@ Default namespace
|
||||
| description | string | | No |
|
||||
| enable_api | boolean | | Yes |
|
||||
| enable_site | boolean | | Yes |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
@@ -12335,7 +12230,7 @@ Default namespace
|
||||
| name | string | | Yes |
|
||||
| permission_keys | [ string ] | | No |
|
||||
| role | string | | No |
|
||||
| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No |
|
||||
| site | [Site](#site) | | No |
|
||||
| tags | [ [Tag](#tag) ] | | No |
|
||||
| tracing | [JSONValue](#jsonvalue) | | No |
|
||||
| updated_at | integer | | No |
|
||||
@@ -12378,7 +12273,6 @@ default (the config form sends the full desired feature state on save).
|
||||
| active_config_is_published | boolean | | No |
|
||||
| app_id | string | | No |
|
||||
| author_name | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| bound_agent_id | string | | No |
|
||||
| create_user_name | string | | No |
|
||||
| created_at | integer | | No |
|
||||
@@ -12386,7 +12280,6 @@ default (the config form sends the full desired feature state on save).
|
||||
| debug_conversation_id | string | | No |
|
||||
| description | string | | No |
|
||||
| has_draft_trigger | boolean | | No |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
@@ -12445,27 +12338,6 @@ default (the config form sends the full desired feature state on save).
|
||||
| date | string | | Yes |
|
||||
| interactions | number | | Yes |
|
||||
|
||||
#### AgentBuildDraftApplyResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| draft | object | | Yes |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentBuildDraftCheckoutPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| force | boolean | Overwrite the existing current-user build draft | No |
|
||||
|
||||
#### AgentBuildDraftResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_soul | object | | Yes |
|
||||
| draft | object | | Yes |
|
||||
| variant | string | | Yes |
|
||||
|
||||
#### AgentCliToolAuthorizationStatus
|
||||
|
||||
Authorization state for Agent-scoped CLI tools.
|
||||
@@ -12528,18 +12400,10 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot_id | string | | No |
|
||||
| app_id | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| description | string | | Yes |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| id | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| role | string | | No |
|
||||
| scope | [AgentScope](#agentscope) | | Yes |
|
||||
| source | [AgentSource](#agentsource) | | No |
|
||||
| status | [AgentStatus](#agentstatus) | | Yes |
|
||||
|
||||
#### AgentComposerBindingResponse
|
||||
@@ -12592,25 +12456,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| current_snapshot_id | string | | No |
|
||||
| workflow_node_count | integer | | Yes |
|
||||
|
||||
#### AgentComposerKnowledgeDatasetCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| id | string | | No |
|
||||
| missing | boolean | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### AgentComposerKnowledgeSetCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentComposerKnowledgeDatasetCandidateResponse](#agentcomposerknowledgedatasetcandidateresponse) ] | | No |
|
||||
| description | string | | No |
|
||||
| id | string | | Yes |
|
||||
| missing_dataset_ids | [ string ] | | No |
|
||||
| name | string | | Yes |
|
||||
|
||||
#### AgentComposerNodeJobCandidatesResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -12626,7 +12471,7 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| cli_tools | [ [AgentCliToolConfig](#agentclitoolconfig) ] | | No |
|
||||
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| knowledge_sets | [ [AgentComposerKnowledgeSetCandidateResponse](#agentcomposerknowledgesetcandidateresponse) ] | | No |
|
||||
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
|
||||
#### AgentComposerSoulLockResponse
|
||||
|
||||
@@ -12645,28 +12490,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| result | string | | Yes |
|
||||
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
|
||||
|
||||
#### AgentConfigDraftSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| account_id | string | | No |
|
||||
| agent_id | string | | Yes |
|
||||
| base_snapshot_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | | Yes |
|
||||
| id | string | | Yes |
|
||||
| updated_at | integer | | No |
|
||||
| updated_by | string | | No |
|
||||
|
||||
#### AgentConfigDraftType
|
||||
|
||||
Editable Agent Soul draft workspace type.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentConfigDraftType | string | Editable Agent Soul draft workspace type. | |
|
||||
|
||||
#### AgentConfigRevisionOperation
|
||||
|
||||
Audit operation recorded for Agent Soul version/revision changes.
|
||||
@@ -12716,8 +12539,6 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot_id | string | | Yes |
|
||||
| draft_config_id | string | | No |
|
||||
| restored_version_id | string | | No |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentConfigSnapshotSummaryResponse
|
||||
@@ -12972,12 +12793,10 @@ Supported icon storage formats for Agent roster entries.
|
||||
| app_id | string | | No |
|
||||
| archived_at | integer | | No |
|
||||
| archived_by | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| description | string | | Yes |
|
||||
| existing_node_ids | [ string ] | | No |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | [AgentIconType](#agenticontype) | | No |
|
||||
@@ -13046,57 +12865,14 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| id | string | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### AgentKnowledgeMetadataCondition
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| comparison_operator | string, <br>**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes |
|
||||
| name | string | | Yes |
|
||||
| value | string<br>[ string ]<br>number | | No |
|
||||
|
||||
#### AgentKnowledgeMetadataConditions
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [ [AgentKnowledgeMetadataCondition](#agentknowledgemetadatacondition) ] | | No |
|
||||
| logical_operator | string, <br>**Available values:** "and", "or", <br>**Default:** and | *Enum:* `"and"`, `"or"` | No |
|
||||
|
||||
#### AgentKnowledgeMetadataFilteringConfig
|
||||
|
||||
Per-set metadata filtering policy.
|
||||
|
||||
The Python attribute uses ``metadata_model_config`` for clarity because the
|
||||
model belongs to metadata filtering specifically, while the external API and
|
||||
generated schema keep the historical ``model_config`` field name via alias.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [AgentKnowledgeMetadataConditions](#agentknowledgemetadataconditions) | | No |
|
||||
| mode | string, <br>**Available values:** "automatic", "disabled", "manual", <br>**Default:** disabled | *Enum:* `"automatic"`, `"disabled"`, `"manual"` | No |
|
||||
| model_config | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
|
||||
|
||||
#### AgentKnowledgeModelConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| completion_params | object | | No |
|
||||
| mode | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### AgentKnowledgeQueryConfig
|
||||
|
||||
Per-set query policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
|
||||
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
|
||||
set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
``value`` while ``generated_query`` leaves that value empty.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | Yes |
|
||||
| value | string | | No |
|
||||
| query | string | | No |
|
||||
| score_threshold | number | | No |
|
||||
| score_threshold_enabled | boolean | | No |
|
||||
| top_k | integer | | No |
|
||||
|
||||
#### AgentKnowledgeQueryMode
|
||||
|
||||
@@ -13104,59 +12880,6 @@ set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentKnowledgeQueryMode | string | | |
|
||||
|
||||
#### AgentKnowledgeRerankingModelConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| model | string | | Yes |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### AgentKnowledgeRetrievalConfig
|
||||
|
||||
Per-set retrieval policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Retrieval settings now live on each knowledge set instead of one shared
|
||||
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
|
||||
``single`` retrieval with a required model config.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| mode | string, <br>**Available values:** "multiple", "single" | *Enum:* `"multiple"`, `"single"` | Yes |
|
||||
| model | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
|
||||
| reranking_enable | boolean, <br>**Default:** true | | No |
|
||||
| reranking_mode | string, <br>**Default:** reranking_model | | No |
|
||||
| reranking_model | [AgentKnowledgeRerankingModelConfig](#agentknowledgererankingmodelconfig) | | No |
|
||||
| score_threshold | number | | No |
|
||||
| top_k | integer | | No |
|
||||
| weights | [AgentKnowledgeWeightedScoreConfig](#agentknowledgeweightedscoreconfig) | | No |
|
||||
|
||||
#### AgentKnowledgeSetConfig
|
||||
|
||||
One explicit knowledge set in Agent v2.
|
||||
|
||||
``knowledge.sets`` replaces the old flat knowledge config. Each set owns
|
||||
its datasets plus query, retrieval, and metadata policies. An individual
|
||||
set must contain at least one dataset id even though the overall knowledge
|
||||
section may be empty, which is how callers express "no knowledge layer".
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | Yes |
|
||||
| description | string | | No |
|
||||
| id | string | | Yes |
|
||||
| metadata_filtering | [AgentKnowledgeMetadataFilteringConfig](#agentknowledgemetadatafilteringconfig) | | No |
|
||||
| name | string | | Yes |
|
||||
| query | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | Yes |
|
||||
| retrieval | [AgentKnowledgeRetrievalConfig](#agentknowledgeretrievalconfig) | | Yes |
|
||||
|
||||
#### AgentKnowledgeWeightedScoreConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword_setting | object | | No |
|
||||
| vector_setting | object | | No |
|
||||
| weight_type | string | | No |
|
||||
|
||||
#### AgentLogConversationItemResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13340,21 +13063,6 @@ section may be empty, which is how callers express "no knowledge layer".
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentProviderResponse | object | | |
|
||||
|
||||
#### AgentPublishPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| version_note | string | Optional note for this published Agent version | No |
|
||||
|
||||
#### AgentPublishResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot | object | | No |
|
||||
| active_config_snapshot_id | string | | Yes |
|
||||
| draft | object | | No |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentPublishedReferenceResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13412,11 +13120,9 @@ section may be empty, which is how callers express "no knowledge layer".
|
||||
| app_id | string | | No |
|
||||
| archived_at | integer | | No |
|
||||
| archived_by | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| description | string | | Yes |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | [AgentIconType](#agenticontype) | | No |
|
||||
@@ -13484,27 +13190,6 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| enabled | boolean | | No |
|
||||
| type | string | | No |
|
||||
|
||||
#### AgentSimpleResultResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentSkillRefConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| file_id | string | | No |
|
||||
| full_archive_file_id | string | | No |
|
||||
| full_archive_key | string | | No |
|
||||
| id | string | | No |
|
||||
| manifest_files | [ string ] | | No |
|
||||
| name | string | | No |
|
||||
| path | string | | No |
|
||||
| skill_md_file_id | string | | No |
|
||||
| skill_md_key | string | | No |
|
||||
|
||||
#### AgentSkillUploadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13531,7 +13216,6 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| app_features | [AgentSoulAppFeaturesConfig](#agentsoulappfeaturesconfig) | | No |
|
||||
| app_variables | [ [AppVariableConfig](#appvariableconfig) ] | | No |
|
||||
| env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No |
|
||||
| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No |
|
||||
| human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No |
|
||||
| knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No |
|
||||
| memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No |
|
||||
@@ -13586,13 +13270,6 @@ old Agent tool payloads can be read while new payloads stay explicit.
|
||||
| secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No |
|
||||
| variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No |
|
||||
|
||||
#### AgentSoulFilesConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No |
|
||||
| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
|
||||
|
||||
#### AgentSoulHumanConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13602,17 +13279,11 @@ old Agent tool payloads can be read while new payloads stay explicit.
|
||||
|
||||
#### AgentSoulKnowledgeConfig
|
||||
|
||||
Top-level Agent v2 knowledge config.
|
||||
|
||||
Agent v2 models knowledge as explicit sets instead of one flat
|
||||
``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
|
||||
list means no knowledge layer should be emitted at runtime, while set-name
|
||||
uniqueness stays case-insensitive because runtime selection addresses sets
|
||||
by name.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| sets | [ [AgentKnowledgeSetConfig](#agentknowledgesetconfig) ] | | No |
|
||||
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
| query_config | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | No |
|
||||
| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No |
|
||||
|
||||
#### AgentSoulMemoryConfig
|
||||
|
||||
@@ -14085,36 +13756,6 @@ Enum class for api provider schema type.
|
||||
| use_icon_as_answer_icon | boolean | | No |
|
||||
| workflow | [WorkflowPartial](#workflowpartial) | | No |
|
||||
|
||||
#### AppDetailSiteResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| access_token | string | | No |
|
||||
| app_base_url | string | | No |
|
||||
| chat_color_theme | string | | No |
|
||||
| chat_color_theme_inverted | boolean | | No |
|
||||
| code | string | | No |
|
||||
| copyright | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| custom_disclaimer | string | | No |
|
||||
| customize_domain | string | | No |
|
||||
| customize_token_strategy | string | | No |
|
||||
| default_language | string | | No |
|
||||
| description | string | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string<br>[IconType](#icontype) | | No |
|
||||
| icon_url | string | | Yes |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| prompt_public | boolean | | No |
|
||||
| show_workflow_steps | boolean | | No |
|
||||
| title | string | | No |
|
||||
| updated_at | integer | | No |
|
||||
| updated_by | string | | No |
|
||||
| use_icon_as_answer_icon | boolean | | No |
|
||||
|
||||
#### AppDetailWithSite
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -14140,7 +13781,7 @@ Enum class for api provider schema type.
|
||||
| model_config | [ModelConfig](#modelconfig) | | No |
|
||||
| name | string | | Yes |
|
||||
| permission_keys | [ string ] | | No |
|
||||
| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No |
|
||||
| site | [Site](#site) | | No |
|
||||
| tags | [ [Tag](#tag) ] | | No |
|
||||
| tracing | [JSONValue](#jsonvalue) | | No |
|
||||
| updated_at | integer | | No |
|
||||
@@ -14284,7 +13925,6 @@ AppMCPServer Status Enum
|
||||
| description | string | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| prompt_public | boolean | | Yes |
|
||||
| show_workflow_steps | boolean | | Yes |
|
||||
@@ -14312,7 +13952,6 @@ AppMCPServer Status Enum
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| prompt_public | boolean | | No |
|
||||
| show_workflow_steps | boolean | | No |
|
||||
@@ -14546,7 +14185,6 @@ Button styles for user actions.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conversation_id | string | Conversation ID | No |
|
||||
| draft_type | string, <br>**Available values:** "debug_build", "draft", <br>**Default:** draft | Agent App debug config source. Use debug_build while the Agent is in build mode.<br>*Enum:* `"debug_build"`, `"draft"` | No |
|
||||
| files | [ object ] | Uploaded files | No |
|
||||
| inputs | object | | Yes |
|
||||
| model_config | object | | No |
|
||||
@@ -19627,7 +19265,6 @@ Simple provider entity response.
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| icon_url | string | | Yes |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| show_workflow_steps | boolean | | Yes |
|
||||
| title | string | | Yes |
|
||||
@@ -20715,12 +20352,6 @@ How a workflow node is bound to an Agent.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| WorkflowAgentBindingType | string | How a workflow node is bound to an Agent. | |
|
||||
|
||||
#### WorkflowAgentComposerQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| snapshot_id | string | | No |
|
||||
|
||||
#### WorkflowAgentComposerResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -20729,11 +20360,8 @@ How a workflow node is bound to an Agent.
|
||||
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | No |
|
||||
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
|
||||
| app_id | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
| binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No |
|
||||
| chat_endpoint | string | | No |
|
||||
| effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No |
|
||||
| hidden_app_backed | boolean | | No |
|
||||
| impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No |
|
||||
| node_id | string | | No |
|
||||
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
|
||||
|
||||
@@ -3999,7 +3999,6 @@ Model class for provider with models response.
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| icon_url | string | | Yes |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| show_workflow_steps | boolean | | Yes |
|
||||
| title | string | | Yes |
|
||||
|
||||
@@ -1006,7 +1006,6 @@ Returns Server-Sent Events stream.
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| icon_url | string | | No |
|
||||
| input_placeholder | string | | No |
|
||||
| privacy_policy | string | | No |
|
||||
| prompt_public | boolean | | No |
|
||||
| show_workflow_steps | boolean | | No |
|
||||
|
||||
@@ -25,7 +25,6 @@ from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
)
|
||||
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
|
||||
|
||||
MAX_CANDIDATES_PER_LIST = 200
|
||||
|
||||
@@ -140,34 +139,19 @@ def soul_candidates(
|
||||
|
||||
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
|
||||
|
||||
dataset_ids = list_agent_soul_knowledge_dataset_ids(soul)
|
||||
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
|
||||
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
|
||||
knowledge_sets: list[dict[str, Any]] = []
|
||||
for knowledge_set in soul.knowledge.sets:
|
||||
missing_dataset_ids: list[str] = []
|
||||
datasets: list[dict[str, Any]] = []
|
||||
for dataset in knowledge_set.datasets:
|
||||
dataset_id = (dataset.id or "").strip()
|
||||
if not dataset_id:
|
||||
continue
|
||||
row = dataset_rows.get(dataset_id)
|
||||
if row is None:
|
||||
missing_dataset_ids.append(dataset_id)
|
||||
datasets.append(
|
||||
{
|
||||
"id": dataset_id,
|
||||
"name": (getattr(row, "name", None) or dataset.name or dataset_id),
|
||||
"description": getattr(row, "description", None) or dataset.description,
|
||||
"missing": row is None,
|
||||
}
|
||||
)
|
||||
knowledge_sets.append(
|
||||
knowledge_datasets: list[dict[str, Any]] = []
|
||||
for dataset in soul.knowledge.datasets:
|
||||
if not dataset.id:
|
||||
continue
|
||||
row = dataset_rows.get(dataset.id)
|
||||
knowledge_datasets.append(
|
||||
{
|
||||
"id": knowledge_set.id,
|
||||
"name": knowledge_set.name,
|
||||
"description": knowledge_set.description,
|
||||
"datasets": datasets,
|
||||
"missing_dataset_ids": missing_dataset_ids,
|
||||
"id": dataset.id,
|
||||
"name": (getattr(row, "name", None) or dataset.name or dataset.id),
|
||||
"description": getattr(row, "description", None) or dataset.description,
|
||||
"missing": row is None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -177,7 +161,7 @@ def soul_candidates(
|
||||
lists = {
|
||||
"dify_tools": dify_tools,
|
||||
"cli_tools": cli_tools,
|
||||
"knowledge_sets": knowledge_sets,
|
||||
"knowledge_datasets": knowledge_datasets,
|
||||
"human_contacts": human_contacts,
|
||||
}
|
||||
capped: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
@@ -11,8 +11,6 @@ from libs.helper import to_timestamp
|
||||
from models import Account
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -39,10 +37,6 @@ from services.agent.errors import (
|
||||
AgentVersionNotFoundError,
|
||||
InvalidComposerConfigError,
|
||||
)
|
||||
from services.agent.knowledge_datasets import (
|
||||
get_tenant_knowledge_dataset_rows,
|
||||
list_missing_tenant_knowledge_dataset_ids,
|
||||
)
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AppService, CreateAppParams
|
||||
from services.entities.agent_entities import (
|
||||
@@ -96,68 +90,26 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non
|
||||
ComposerConfigValidator.validate_draft_save_payload(payload)
|
||||
|
||||
|
||||
def _agent_soul_config_json(agent_soul: AgentSoulConfig | dict[str, Any]) -> dict[str, Any]:
|
||||
return AgentSoulConfig.model_validate(agent_soul).model_dump(mode="json")
|
||||
|
||||
|
||||
class AgentComposerService:
|
||||
@classmethod
|
||||
def load_workflow_composer(
|
||||
cls, *, tenant_id: str, app_id: str, node_id: str, snapshot_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]:
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
if not binding:
|
||||
if snapshot_id:
|
||||
raise AgentVersionNotFoundError()
|
||||
return cls._empty_workflow_state(app_id=app_id, workflow_id=workflow.id, node_id=node_id)
|
||||
|
||||
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
|
||||
version = cls._workflow_composer_version(
|
||||
tenant_id=tenant_id,
|
||||
binding=binding,
|
||||
agent=agent,
|
||||
snapshot_id=snapshot_id,
|
||||
)
|
||||
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
|
||||
|
||||
@classmethod
|
||||
def _workflow_composer_version(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
binding: WorkflowAgentNodeBinding,
|
||||
agent: Agent | None,
|
||||
snapshot_id: str | None,
|
||||
) -> AgentConfigSnapshot | None:
|
||||
if snapshot_id:
|
||||
if agent is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT:
|
||||
if agent.scope != AgentScope.ROSTER:
|
||||
raise AgentVersionNotFoundError()
|
||||
elif binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
|
||||
if (
|
||||
agent.scope != AgentScope.WORKFLOW_ONLY
|
||||
or agent.app_id != binding.app_id
|
||||
or agent.workflow_id != binding.workflow_id
|
||||
or agent.workflow_node_id != binding.node_id
|
||||
):
|
||||
raise AgentVersionNotFoundError()
|
||||
else:
|
||||
raise AgentVersionNotFoundError()
|
||||
return cls._require_version(tenant_id=tenant_id, agent_id=agent.id, version_id=snapshot_id)
|
||||
|
||||
version_id = (
|
||||
agent.active_config_snapshot_id
|
||||
if agent and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
|
||||
else binding.current_snapshot_id
|
||||
)
|
||||
return cls._get_version_if_present(
|
||||
version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id if agent else None,
|
||||
version_id=version_id,
|
||||
)
|
||||
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
|
||||
|
||||
@classmethod
|
||||
def save_workflow_composer(
|
||||
@@ -168,7 +120,6 @@ class AgentComposerService:
|
||||
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
|
||||
@@ -308,37 +259,31 @@ class AgentComposerService:
|
||||
|
||||
@classmethod
|
||||
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
|
||||
agent = cls._require_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent)
|
||||
|
||||
@classmethod
|
||||
def load_agent_composer(cls, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent)
|
||||
|
||||
@classmethod
|
||||
def _load_agent_composer_for_agent(cls, *, tenant_id: str, agent: Agent) -> dict[str, Any]:
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=agent.updated_by or agent.created_by,
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
version = cls._get_version_if_present(
|
||||
if not agent:
|
||||
raise AgentNotFoundError()
|
||||
version = cls._require_version(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
return {
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"agent": cls._serialize_agent(agent),
|
||||
"active_config_snapshot": cls._serialize_version(version),
|
||||
"draft": cls._serialize_draft(draft),
|
||||
"agent_soul": draft.config_snapshot_dict,
|
||||
"save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
|
||||
"app_id": agent.app_id,
|
||||
"backing_app_id": agent.backing_app_id or agent.app_id,
|
||||
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
|
||||
"chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages",
|
||||
"agent_soul": version.config_snapshot_dict,
|
||||
"save_options": [
|
||||
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value,
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -347,17 +292,22 @@ class AgentComposerService:
|
||||
) -> dict[str, Any]:
|
||||
if payload.variant != ComposerVariant.AGENT_APP:
|
||||
raise ValueError("Agent App composer endpoint only accepts agent_app variant")
|
||||
if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION:
|
||||
raise InvalidComposerConfigError(
|
||||
"Agent App composer only saves the normal draft. Use the publish endpoint to create a version."
|
||||
)
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
|
||||
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if not agent:
|
||||
agent = Agent(
|
||||
tenant_id=tenant_id,
|
||||
@@ -367,7 +317,6 @@ class AgentComposerService:
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
app_id=app_id,
|
||||
backing_app_id=app_id,
|
||||
status=AgentStatus.ACTIVE,
|
||||
created_by=account_id,
|
||||
updated_by=account_id,
|
||||
@@ -378,59 +327,35 @@ class AgentComposerService:
|
||||
except IntegrityError as exc:
|
||||
db.session.rollback()
|
||||
raise AgentNameConflictError() from exc
|
||||
return cls._save_agent_composer_for_agent(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
account_id=account_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def save_agent_composer(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload
|
||||
) -> dict[str, Any]:
|
||||
if payload.variant != ComposerVariant.AGENT_APP:
|
||||
raise ValueError("Agent composer endpoint only accepts agent_app variant")
|
||||
if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION:
|
||||
raise InvalidComposerConfigError(
|
||||
"Agent composer only saves the normal draft. Use the publish endpoint to create a version."
|
||||
if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id:
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
version_note=payload.version_note,
|
||||
)
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return cls._save_agent_composer_for_agent(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
account_id=account_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _save_agent_composer_for_agent(
|
||||
cls, *, tenant_id: str, agent: Agent, account_id: str, payload: ComposerSavePayload
|
||||
) -> dict[str, Any]:
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
agent_soul=payload.agent_soul,
|
||||
account_id_for_audit=account_id,
|
||||
)
|
||||
agent.updated_by = account_id
|
||||
agent.active_config_is_published = cls._agent_soul_matches_active_config(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
else:
|
||||
current_snapshot = cls._require_version(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
version = cls._update_current_version(
|
||||
current_snapshot=current_snapshot,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
version_note=payload.version_note,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.updated_by = account_id
|
||||
|
||||
db.session.commit()
|
||||
state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id)
|
||||
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
|
||||
state["validation"] = cls.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
@@ -438,214 +363,6 @@ class AgentComposerService:
|
||||
)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def _agent_soul_matches_active_config(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> bool:
|
||||
if not agent.active_config_snapshot_id:
|
||||
return False
|
||||
|
||||
active_version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
if not active_version:
|
||||
return False
|
||||
if agent.source == AgentSource.AGENT_APP and not cls._has_publish_visible_revision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
):
|
||||
return False
|
||||
|
||||
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict)
|
||||
|
||||
@classmethod
|
||||
def _has_publish_visible_revision(cls, *, tenant_id: str, agent_id: str, snapshot_id: str) -> bool:
|
||||
revisions = db.session.scalars(
|
||||
select(AgentConfigRevision.operation).where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
AgentConfigRevision.current_snapshot_id == snapshot_id,
|
||||
)
|
||||
).all()
|
||||
|
||||
return any(
|
||||
operation
|
||||
in {
|
||||
AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
}
|
||||
for operation in revisions
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def publish_agent_app_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP:
|
||||
raise AgentNotFoundError()
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=account_id,
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
ComposerConfigValidator.validate_publish_payload(
|
||||
ComposerSavePayload(
|
||||
variant=ComposerVariant.AGENT_APP,
|
||||
agent_soul=agent_soul,
|
||||
save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
|
||||
version_note=version_note,
|
||||
)
|
||||
)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul)
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
version_note=version_note,
|
||||
previous_snapshot_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
agent.updated_by = account_id
|
||||
draft.base_snapshot_id = version.id
|
||||
draft.updated_by = account_id
|
||||
db.session.commit()
|
||||
return {
|
||||
"result": "success",
|
||||
"active_config_snapshot_id": version.id,
|
||||
"active_config_snapshot": cls._serialize_version(version),
|
||||
"draft": cls._serialize_draft(draft),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def checkout_agent_app_build_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, force: bool = False
|
||||
) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
normal_draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=account_id,
|
||||
)
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is not None and not force:
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
if build_draft is None:
|
||||
build_draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
draft_owner_key=account_id,
|
||||
created_by=account_id,
|
||||
)
|
||||
db.session.add(build_draft)
|
||||
build_draft.base_snapshot_id = normal_draft.base_snapshot_id
|
||||
build_draft.config_snapshot = AgentSoulConfig.model_validate(normal_draft.config_snapshot_dict)
|
||||
build_draft.updated_by = account_id
|
||||
db.session.commit()
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
|
||||
@classmethod
|
||||
def load_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
|
||||
@classmethod
|
||||
def save_agent_app_build_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload
|
||||
) -> dict[str, Any]:
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
ComposerConfigValidator.validate_draft_save_payload(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
build_draft = cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
account_id_for_audit=account_id,
|
||||
)
|
||||
db.session.commit()
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
|
||||
@classmethod
|
||||
def apply_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict)
|
||||
normal_draft = cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
agent_soul=applied_agent_soul,
|
||||
account_id_for_audit=account_id,
|
||||
base_snapshot_id=build_draft.base_snapshot_id,
|
||||
)
|
||||
agent.active_config_is_published = cls._agent_soul_matches_active_config(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
agent_soul=applied_agent_soul,
|
||||
)
|
||||
agent.updated_by = account_id
|
||||
db.session.delete(build_draft)
|
||||
db.session.commit()
|
||||
return {"result": "success", "draft": cls._serialize_draft(normal_draft)}
|
||||
|
||||
@classmethod
|
||||
def discard_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is not None:
|
||||
db.session.delete(build_draft)
|
||||
db.session.commit()
|
||||
return {"result": "success"}
|
||||
|
||||
@classmethod
|
||||
def collect_validation_findings(
|
||||
cls,
|
||||
@@ -655,15 +372,19 @@ class AgentComposerService:
|
||||
agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 soft findings, with DB-backed dataset and drive mention checks."""
|
||||
existing_knowledge_set_ids = (
|
||||
{knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets}
|
||||
if payload.agent_soul is not None
|
||||
else None
|
||||
)
|
||||
findings = ComposerConfigValidator.collect_soft_findings(
|
||||
payload,
|
||||
existing_knowledge_set_ids=existing_knowledge_set_ids,
|
||||
)
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
|
||||
mentioned_ids: set[str] = set()
|
||||
if payload.agent_soul is not None:
|
||||
mentioned_ids |= {
|
||||
mention.ref_id
|
||||
for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt)
|
||||
if mention.kind == MentionKind.KNOWLEDGE
|
||||
}
|
||||
existing_dataset_ids: set[str] | None = None
|
||||
if mentioned_ids:
|
||||
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
|
||||
findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
|
||||
if agent_id and payload.agent_soul is not None:
|
||||
findings["warnings"].extend(
|
||||
cls._drive_mention_findings(
|
||||
@@ -674,24 +395,6 @@ class AgentComposerService:
|
||||
)
|
||||
return findings
|
||||
|
||||
@classmethod
|
||||
def validate_knowledge_datasets(cls, *, tenant_id: str, agent_soul: AgentSoulConfig | None) -> None:
|
||||
"""Hard-validate tenant-scoped knowledge set datasets before saving.
|
||||
|
||||
DTO validators own set shape, duplicate set ids/names, and duplicate
|
||||
dataset ids within one set. This service-level check owns database
|
||||
existence and tenant ownership so invalid or cross-tenant datasets fail
|
||||
before Agent Soul snapshots are persisted.
|
||||
"""
|
||||
if agent_soul is None:
|
||||
return
|
||||
missing_ids = list_missing_tenant_knowledge_dataset_ids(tenant_id=tenant_id, agent_soul=agent_soul)
|
||||
if missing_ids:
|
||||
raise InvalidComposerConfigError(
|
||||
"knowledge_dataset_not_found: knowledge sets reference missing or out-of-scope datasets: "
|
||||
+ ", ".join(missing_ids)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None:
|
||||
"""The Agent App's bound roster agent id, if any (validate-endpoint context)."""
|
||||
@@ -806,7 +509,7 @@ class AgentComposerService:
|
||||
|
||||
soul_lists, soul_truncated = soul_candidates(
|
||||
agent_soul=agent_soul,
|
||||
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
|
||||
)
|
||||
truncated = truncated or soul_truncated
|
||||
@@ -826,14 +529,14 @@ class AgentComposerService:
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
@classmethod
|
||||
def get_agent_app_candidates(cls, *, tenant_id: str, agent_id: str, user_id: str) -> dict[str, Any]:
|
||||
def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]:
|
||||
"""Slash-menu data source for the Agent App (Console) composer (ENG-615)."""
|
||||
from services.agent.composer_candidates import soul_candidates
|
||||
|
||||
agent_soul = cls._load_agent_soul(tenant_id=tenant_id, agent_id=agent_id)
|
||||
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
|
||||
soul_lists, truncated = soul_candidates(
|
||||
agent_soul=agent_soul,
|
||||
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
|
||||
)
|
||||
response = ComposerCandidatesResponse(
|
||||
@@ -865,18 +568,24 @@ class AgentComposerService:
|
||||
return cls._parse_soul_snapshot(version)
|
||||
|
||||
@classmethod
|
||||
def _load_agent_soul(cls, *, tenant_id: str, agent_id: str) -> AgentSoulConfig | None:
|
||||
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=agent_id)
|
||||
def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
return None
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=agent.updated_by or agent.created_by,
|
||||
version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
return AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
return cls._parse_soul_snapshot(version)
|
||||
|
||||
@staticmethod
|
||||
def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
|
||||
@@ -920,6 +629,30 @@ class AgentComposerService:
|
||||
variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id)
|
||||
return [(variable.name, variable.value_type.value) for variable in variables.variables]
|
||||
|
||||
@staticmethod
|
||||
def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
|
||||
"""Tenant-scoped dataset lookup tolerating malformed ids.
|
||||
|
||||
Mention ids come from user-editable prompt text; a non-UUID id can never
|
||||
match a dataset row, so it is simply absent from the result (-> missing/
|
||||
placeholder semantics) instead of breaking the UUID-typed query.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from services.dataset_service import DatasetService
|
||||
|
||||
valid_ids: list[str] = []
|
||||
for dataset_id in dataset_ids:
|
||||
try:
|
||||
UUID(dataset_id)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
valid_ids.append(dataset_id)
|
||||
if not valid_ids:
|
||||
return {}
|
||||
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
|
||||
return {str(row.id): row for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Workspace Dify Plugin tools, same source as the tool selector.
|
||||
@@ -1047,7 +780,6 @@ class AgentComposerService:
|
||||
raise ValueError("Inline workflow agent binding must point to a workflow-only agent")
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
agent.updated_by = account_id
|
||||
binding.current_snapshot_id = version.id
|
||||
binding.updated_by = account_id
|
||||
@@ -1146,7 +878,6 @@ class AgentComposerService:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
agent.updated_by = account_id
|
||||
binding.current_snapshot_id = version.id
|
||||
if payload.node_job is not None:
|
||||
@@ -1177,7 +908,6 @@ class AgentComposerService:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
agent.updated_by = account_id
|
||||
binding.current_snapshot_id = version.id
|
||||
binding.updated_by = account_id
|
||||
@@ -1298,15 +1028,6 @@ class AgentComposerService:
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
) -> Agent:
|
||||
backing_app = AgentRosterService(db.session).create_hidden_backing_app_for_workflow_agent(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
name=name or f"Workflow Agent {node_id}",
|
||||
description=description,
|
||||
icon_type=icon_type,
|
||||
icon=icon,
|
||||
icon_background=icon_background,
|
||||
)
|
||||
agent = Agent(
|
||||
tenant_id=tenant_id,
|
||||
name=name or f"Workflow Agent {node_id}",
|
||||
@@ -1319,7 +1040,6 @@ class AgentComposerService:
|
||||
scope=AgentScope.WORKFLOW_ONLY,
|
||||
source=AgentSource.WORKFLOW,
|
||||
app_id=app_id,
|
||||
backing_app_id=backing_app.id,
|
||||
workflow_id=workflow_id,
|
||||
workflow_node_id=node_id,
|
||||
status=AgentStatus.ACTIVE,
|
||||
@@ -1338,7 +1058,6 @@ class AgentComposerService:
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
return agent
|
||||
|
||||
@classmethod
|
||||
@@ -1486,7 +1205,6 @@ class AgentComposerService:
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
agent.updated_by = account_id
|
||||
return agent
|
||||
|
||||
@@ -1567,145 +1285,6 @@ class AgentComposerService:
|
||||
or 0
|
||||
) + 1
|
||||
|
||||
@classmethod
|
||||
def _get_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent | None:
|
||||
return db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _require_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent:
|
||||
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
return agent
|
||||
|
||||
@classmethod
|
||||
def _get_agent_draft(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
draft_type: AgentConfigDraftType,
|
||||
account_id: str | None,
|
||||
) -> AgentConfigDraft | None:
|
||||
stmt = select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent_id,
|
||||
AgentConfigDraft.draft_type == draft_type,
|
||||
)
|
||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
|
||||
else:
|
||||
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
|
||||
return db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
|
||||
|
||||
@classmethod
|
||||
def _get_or_create_agent_draft(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
draft_type: AgentConfigDraftType,
|
||||
account_id: str | None,
|
||||
created_by: str | None,
|
||||
) -> AgentConfigDraft:
|
||||
draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=draft_type,
|
||||
account_id=account_id,
|
||||
)
|
||||
if draft is not None:
|
||||
return draft
|
||||
base_snapshot = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
agent_soul = (
|
||||
AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict)
|
||||
if base_snapshot is not None
|
||||
else AgentSoulConfig()
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=draft_type,
|
||||
account_id=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
|
||||
draft_owner_key=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD and account_id else "",
|
||||
base_snapshot_id=base_snapshot.id if base_snapshot else None,
|
||||
config_snapshot=agent_soul,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
)
|
||||
db.session.add(draft)
|
||||
db.session.flush()
|
||||
return draft
|
||||
|
||||
@classmethod
|
||||
def _save_agent_draft(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
draft_type: AgentConfigDraftType,
|
||||
account_id: str | None,
|
||||
agent_soul: AgentSoulConfig,
|
||||
account_id_for_audit: str,
|
||||
base_snapshot_id: str | None = None,
|
||||
) -> AgentConfigDraft:
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=draft_type,
|
||||
account_id=account_id,
|
||||
created_by=account_id_for_audit,
|
||||
)
|
||||
draft.config_snapshot = agent_soul
|
||||
if base_snapshot_id is not None:
|
||||
draft.base_snapshot_id = base_snapshot_id
|
||||
elif draft.base_snapshot_id is None:
|
||||
draft.base_snapshot_id = agent.active_config_snapshot_id
|
||||
draft.updated_by = account_id_for_audit
|
||||
if draft_type == AgentConfigDraftType.DRAFT and account_id is None:
|
||||
agent.active_config_is_published = False
|
||||
db.session.flush()
|
||||
return draft
|
||||
|
||||
@classmethod
|
||||
def _serialize_draft(cls, draft: AgentConfigDraft | None) -> dict[str, Any] | None:
|
||||
if draft is None:
|
||||
return None
|
||||
return {
|
||||
"id": draft.id,
|
||||
"agent_id": draft.agent_id,
|
||||
"draft_type": draft.draft_type.value,
|
||||
"account_id": draft.account_id,
|
||||
"base_snapshot_id": draft.base_snapshot_id,
|
||||
"created_by": draft.created_by,
|
||||
"updated_by": draft.updated_by,
|
||||
"created_at": to_timestamp(draft.created_at),
|
||||
"updated_at": to_timestamp(draft.updated_at),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_build_draft_state(cls, draft: AgentConfigDraft) -> dict[str, Any]:
|
||||
return {
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"draft": cls._serialize_draft(draft),
|
||||
"agent_soul": draft.config_snapshot_dict,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_draft_workflow(cls, *, tenant_id: str, app_id: str) -> Workflow:
|
||||
workflow = db.session.scalar(
|
||||
@@ -1892,12 +1471,6 @@ class AgentComposerService:
|
||||
"impact_summary": cls.calculate_impact(tenant_id=binding.tenant_id, current_snapshot_id=version.id)
|
||||
if version
|
||||
else None,
|
||||
"app_id": binding.app_id,
|
||||
"backing_app_id": agent.backing_app_id if agent else None,
|
||||
"hidden_app_backed": bool(agent and agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
|
||||
"chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages" if agent else None,
|
||||
"workflow_id": binding.workflow_id,
|
||||
"node_id": binding.node_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -1906,15 +1479,7 @@ class AgentComposerService:
|
||||
"id": agent.id,
|
||||
"name": agent.name,
|
||||
"description": agent.description,
|
||||
"role": agent.role,
|
||||
"icon_type": agent.icon_type,
|
||||
"icon": agent.icon,
|
||||
"icon_background": agent.icon_background,
|
||||
"scope": agent.scope.value,
|
||||
"source": agent.source.value,
|
||||
"app_id": agent.app_id,
|
||||
"backing_app_id": agent.backing_app_id or agent.app_id,
|
||||
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
|
||||
"status": agent.status.value,
|
||||
"active_config_snapshot_id": agent.active_config_snapshot_id,
|
||||
}
|
||||
|
||||
@@ -148,15 +148,15 @@ class ComposerConfigValidator:
|
||||
cls,
|
||||
payload: ComposerSavePayload,
|
||||
*,
|
||||
existing_knowledge_set_ids: set[str] | None = None,
|
||||
existing_dataset_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 §5.3/§5.4 soft findings — never block save.
|
||||
|
||||
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
|
||||
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge-set
|
||||
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
|
||||
mentions with a placeholder name (0522 consensus) instead of dropping or
|
||||
rejecting them. With ``existing_knowledge_set_ids`` provided, mentions
|
||||
that no longer exist in the current Agent Soul surface as placeholders too.
|
||||
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
|
||||
deleted datasets surface as placeholders too.
|
||||
"""
|
||||
warnings: list[dict[str, Any]] = []
|
||||
placeholders: list[dict[str, str]] = []
|
||||
@@ -188,7 +188,7 @@ class ComposerConfigValidator:
|
||||
resolved = resolver(mention)
|
||||
if mention.kind == MentionKind.KNOWLEDGE:
|
||||
dangling = resolved is None or (
|
||||
existing_knowledge_set_ids is not None and mention.ref_id not in existing_knowledge_set_ids
|
||||
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
|
||||
)
|
||||
if dangling:
|
||||
placeholders.append(
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def list_agent_soul_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
|
||||
"""Return normalized unique knowledge dataset ids in config order.
|
||||
|
||||
Agent v2 knowledge dataset selection is owned by ``knowledge.sets``. This
|
||||
helper keeps composer, workflow validation, candidates, and runtime
|
||||
diagnostics aligned on the same normalization rules: strip whitespace, drop
|
||||
blanks, preserve first-seen order, and deduplicate.
|
||||
"""
|
||||
dataset_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for knowledge_set in agent_soul.knowledge.sets:
|
||||
for dataset in knowledge_set.datasets:
|
||||
dataset_id = (dataset.id or "").strip()
|
||||
if not dataset_id or dataset_id in seen:
|
||||
continue
|
||||
seen.add(dataset_id)
|
||||
dataset_ids.append(dataset_id)
|
||||
return dataset_ids
|
||||
|
||||
|
||||
def get_tenant_knowledge_dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
|
||||
"""Return tenant-scoped dataset rows for normalized knowledge dataset ids.
|
||||
|
||||
Knowledge ids come from user-editable config. Malformed ids can never match
|
||||
a dataset row, so they are treated as missing instead of breaking the
|
||||
UUID-typed dataset lookup.
|
||||
"""
|
||||
from services.dataset_service import DatasetService
|
||||
|
||||
valid_ids: list[str] = []
|
||||
for dataset_id in dataset_ids:
|
||||
try:
|
||||
UUID(dataset_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
valid_ids.append(dataset_id)
|
||||
|
||||
if not valid_ids:
|
||||
return {}
|
||||
|
||||
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
|
||||
return {str(row.id): row for row in rows}
|
||||
|
||||
|
||||
def list_missing_tenant_knowledge_dataset_ids(*, tenant_id: str, agent_soul: AgentSoulConfig | None) -> list[str]:
|
||||
"""Return normalized knowledge dataset ids missing from the tenant scope."""
|
||||
if agent_soul is None:
|
||||
return []
|
||||
|
||||
dataset_ids = list_agent_soul_knowledge_dataset_ids(agent_soul)
|
||||
if not dataset_ids:
|
||||
return []
|
||||
|
||||
rows = get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=dataset_ids)
|
||||
return [dataset_id for dataset_id in dataset_ids if dataset_id not in rows]
|
||||
@@ -1,26 +1,23 @@
|
||||
"""Prompt mention and workflow-marker parsing helpers for Agent surfaces.
|
||||
"""Prompt mention (slash-reference) serialization contract — ENG-616.
|
||||
|
||||
Slash-menu insertions are stored inline as mention tokens:
|
||||
Slash-menu insertions are stored inline in the plain-string prompt as tokens:
|
||||
|
||||
[§<kind>:<id>[:<label>]§]
|
||||
|
||||
Those tokens point at Agent-owned config such as Soul tools/knowledge/humans or
|
||||
workflow task config such as ``previous_node_output_refs`` / ``declared_outputs``.
|
||||
Runtime mention expansion is owned by the run-request builders.
|
||||
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent
|
||||
runtime context. For prompt-owned entities that means Agent Soul lists such as
|
||||
``tools`` / ``knowledge.datasets`` / ``human.contacts`` and workflow job lists
|
||||
such as ``previous_node_output_refs`` / ``declared_outputs``. For drive-backed
|
||||
``skill`` / ``file`` mentions the field stores a URL-encoded drive key and is
|
||||
resolved against ``agent_drive_files`` at runtime. ``label`` is an optional
|
||||
plain-text fallback only. A single ``:`` separates all three fields; ``label``
|
||||
is the trailing remainder and may itself contain ``:``.
|
||||
|
||||
Workflow Agent tasks also carry frontend workflow variable markers:
|
||||
|
||||
{{#<node-id>.<output>[.<child>...]#}}
|
||||
|
||||
Those frontend markers are a separate path from slash-reference expansion. They
|
||||
are parsed here only to derive ``previous_node_output_refs`` from the current
|
||||
task text. The markers remain literal in the workflow task prompt, while their
|
||||
resolved values appear under the workflow context prompt's ``Previous node
|
||||
outputs:`` section. Legacy ``[§node_output:...§]`` mention syntax is not part
|
||||
of that derivation path.
|
||||
|
||||
Frontend output blocks still accept a legacy bare ``§output:...§`` form during
|
||||
migrations, so the mention parser keeps that alias for output mentions only.
|
||||
The ``[§…§]`` wrapper uses the section sign ``§`` (U+00A7), which never appears
|
||||
in Dify template syntax (``{{var}}`` / ``{{#a.b#}}``) nor in normal prompt text,
|
||||
so these tokens can never collide with the existing template parsers. Runtime
|
||||
expansion (and the final scrub that guarantees no internal marker ever reaches
|
||||
the model) is owned by the run-request builders.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -50,16 +47,13 @@ class MentionKind(StrEnum):
|
||||
|
||||
|
||||
MENTION_PATTERN = re.compile(
|
||||
r"(?:\[§(?P<bracket_kind>skill|file|tool|cli_tool|knowledge|human|node_output|output):"
|
||||
r"(?P<bracket_id>[^:§]+?)(?::(?P<bracket_label>[^§]*?))?§\])"
|
||||
r"|(?:§(?P<legacy_kind>output):(?P<legacy_id>[^:§]+?)(?::(?P<legacy_label>[^§]*?))?§)"
|
||||
r"\[§(skill|file|tool|cli_tool|knowledge|human|node_output|output):([^:§]+?)(?::([^§]*?))?§\]"
|
||||
)
|
||||
# Anything mention-shaped (``[§word:…§]``) that the strict pattern did not consume
|
||||
# — unknown kinds, malformed bodies. The ``§`` wrapper + a kind-word + ``:``
|
||||
# requirement keeps legacy ``{{#histories#}}`` / ``{{var}}`` template forms and
|
||||
# ordinary bracketed text out of scope.
|
||||
_RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\]")
|
||||
WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}")
|
||||
|
||||
MAX_MENTIONS_PER_PROMPT = 200
|
||||
# Drive keys are validated up to 512 Unicode code points before URL encoding.
|
||||
@@ -77,8 +71,7 @@ MAX_MENTION_LABEL_LENGTH = 255
|
||||
ALL_PROVIDER_TOOLS_SUFFIX = "*"
|
||||
|
||||
# Per-surface allowlists (design §2.4): the soul prompt may only reference
|
||||
# soul-owned entities; the persisted workflow task prompt may only reference
|
||||
# run-scoped ones.
|
||||
# soul-owned entities; the workflow job prompt may only reference run-scoped ones.
|
||||
SOUL_PROMPT_ALLOWED_KINDS = frozenset(
|
||||
{
|
||||
MentionKind.SKILL,
|
||||
@@ -90,9 +83,6 @@ SOUL_PROMPT_ALLOWED_KINDS = frozenset(
|
||||
}
|
||||
)
|
||||
NODE_JOB_PROMPT_ALLOWED_KINDS = frozenset({MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN})
|
||||
WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES = frozenset(
|
||||
{"sys", "env", "conversation", "rag", "current", "last_run", "error_message", "$output"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -110,24 +100,18 @@ class PromptMention:
|
||||
MentionResolver = Callable[[PromptMention], str | None]
|
||||
|
||||
|
||||
def _mention_groups(match: re.Match[str]) -> tuple[str, str, str | None]:
|
||||
kind = match.group("bracket_kind") or match.group("legacy_kind")
|
||||
ref_id = match.group("bracket_id") or match.group("legacy_id")
|
||||
label = match.group("bracket_label") or match.group("legacy_label")
|
||||
return kind, ref_id, label
|
||||
|
||||
|
||||
def parse_prompt_mentions(prompt: str) -> list[PromptMention]:
|
||||
"""Extract well-formed mentions. Oversized id/label tokens are skipped here
|
||||
(treated as malformed) — the runtime scrub still degrades them safely."""
|
||||
mentions: list[PromptMention] = []
|
||||
for match in MENTION_PATTERN.finditer(prompt or ""):
|
||||
kind, ref_id, label = _mention_groups(match)
|
||||
ref_id = match.group(2)
|
||||
label = match.group(3)
|
||||
if len(ref_id) > MAX_MENTION_REF_ID_LENGTH or (label is not None and len(label) > MAX_MENTION_LABEL_LENGTH):
|
||||
continue
|
||||
mentions.append(
|
||||
PromptMention(
|
||||
kind=MentionKind(kind),
|
||||
kind=MentionKind(match.group(1)),
|
||||
ref_id=ref_id,
|
||||
label=label or None,
|
||||
start=match.start(),
|
||||
@@ -146,13 +130,13 @@ def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
|
||||
return prompt
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
kind, ref_id, label = _mention_groups(match)
|
||||
label = label or None
|
||||
ref_id = match.group(2)
|
||||
label = match.group(3) or None
|
||||
fallback = (label or ref_id)[:MAX_MENTION_LABEL_LENGTH]
|
||||
if len(ref_id) > MAX_MENTION_REF_ID_LENGTH or (label is not None and len(label) > MAX_MENTION_LABEL_LENGTH):
|
||||
return fallback
|
||||
mention = PromptMention(
|
||||
kind=MentionKind(kind),
|
||||
kind=MentionKind(match.group(1)),
|
||||
ref_id=ref_id,
|
||||
label=label,
|
||||
start=match.start(),
|
||||
@@ -177,48 +161,6 @@ def find_malformed_mention_markers(prompt: str) -> list[str]:
|
||||
return [match.group(0) for match in _RESIDUAL_MENTION_PATTERN.finditer(prompt) if match.span() not in parsed_spans]
|
||||
|
||||
|
||||
def extract_workflow_variable_selectors(prompt: str) -> list[tuple[str, ...]]:
|
||||
"""Extract ``{{#node.output#}}``-style selectors from workflow prompts."""
|
||||
selectors: list[tuple[str, ...]] = []
|
||||
for match in WORKFLOW_VARIABLE_PATTERN.finditer(prompt or ""):
|
||||
parts = tuple(part.strip() for part in match.group(1).split(".") if part.strip())
|
||||
if len(parts) >= 2:
|
||||
selectors.append(parts)
|
||||
return selectors
|
||||
|
||||
|
||||
def extract_workflow_node_output_selectors(prompt: str) -> list[tuple[str, ...]]:
|
||||
"""Extract previous-node selectors from frontend workflow variable markers.
|
||||
|
||||
Reserved Dify namespaces such as ``sys`` are excluded because they are not
|
||||
previous nodes.
|
||||
"""
|
||||
selectors: list[tuple[str, ...]] = []
|
||||
seen: set[tuple[str, ...]] = set()
|
||||
for selector in extract_workflow_variable_selectors(prompt):
|
||||
if selector[0] in WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES:
|
||||
continue
|
||||
if selector in seen:
|
||||
continue
|
||||
selectors.append(selector)
|
||||
seen.add(selector)
|
||||
return selectors
|
||||
|
||||
|
||||
def workflow_previous_node_output_refs_from_selectors(
|
||||
selectors: list[tuple[str, ...]],
|
||||
) -> list[WorkflowPreviousNodeOutputRef]:
|
||||
"""Materialize persisted previous-node refs from parsed frontend selectors."""
|
||||
return [
|
||||
WorkflowPreviousNodeOutputRef(
|
||||
selector=list(selector),
|
||||
node_id=selector[0],
|
||||
output=selector[1],
|
||||
)
|
||||
for selector in selectors
|
||||
]
|
||||
|
||||
|
||||
def scrub_mention_markers(text: str) -> str:
|
||||
"""Degrade any residual mention-shaped ``[§kind:…§]`` marker to readable text."""
|
||||
|
||||
@@ -269,9 +211,9 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
|
||||
if mention.ref_id in (cli_tool.id, cli_tool.name):
|
||||
return cli_tool.name or cli_tool.id
|
||||
case MentionKind.KNOWLEDGE:
|
||||
for knowledge_set in agent_soul.knowledge.sets:
|
||||
if mention.ref_id == knowledge_set.id:
|
||||
return knowledge_set.name or knowledge_set.id
|
||||
for dataset in agent_soul.knowledge.datasets:
|
||||
if mention.ref_id == dataset.id:
|
||||
return dataset.name or dataset.id
|
||||
case MentionKind.HUMAN:
|
||||
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
|
||||
case _:
|
||||
@@ -282,17 +224,14 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
|
||||
|
||||
|
||||
def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionResolver:
|
||||
"""Resolve persisted workflow task prompt mentions.
|
||||
|
||||
``node_output`` expands to the stored reference name only; values stay in
|
||||
the workflow context block for the run-scoped ``user_prompt``.
|
||||
"""
|
||||
"""Resolve job-surface mentions. ``node_output`` expands to the stored
|
||||
reference name only — values stay in the Workflow context block (design §4.2)."""
|
||||
|
||||
def _resolve(mention: PromptMention) -> str | None:
|
||||
match mention.kind:
|
||||
case MentionKind.NODE_OUTPUT:
|
||||
for ref in node_job.previous_node_output_refs:
|
||||
selector = normalize_previous_node_output_selector(ref)
|
||||
selector = _selector_from_ref(ref)
|
||||
if selector and f"{selector[0]}.{selector[1]}" == mention.ref_id:
|
||||
return ref.name or mention.label or mention.ref_id
|
||||
case MentionKind.OUTPUT:
|
||||
@@ -317,27 +256,14 @@ def _resolve_human_contact(contacts: list[AgentHumanContactConfig], ref_id: str)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_previous_node_output_selector(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, ...] | None:
|
||||
"""Return the canonical previous-node selector for a persisted ref.
|
||||
|
||||
Explicit selector arrays win and must contain at least two string parts. The
|
||||
legacy field form falls back to ``node_id`` plus the first available output
|
||||
field. Callers that only need node/output identity should compare the first
|
||||
two returned items.
|
||||
"""
|
||||
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] | None:
|
||||
for candidate in (ref.selector, ref.variable_selector, ref.value_selector):
|
||||
if isinstance(candidate, list) and len(candidate) >= 2:
|
||||
selector_parts: list[str] = []
|
||||
for item in candidate:
|
||||
if not isinstance(item, str):
|
||||
break
|
||||
selector_parts.append(item)
|
||||
if len(selector_parts) == len(candidate):
|
||||
return tuple(selector_parts)
|
||||
node_id = ref.get("node_id")
|
||||
output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key")
|
||||
if isinstance(node_id, str) and isinstance(output_name, str):
|
||||
return node_id, output_name
|
||||
return str(candidate[0]), str(candidate[1])
|
||||
if ref.node_id:
|
||||
output = ref.output or ref.variable or ref.key
|
||||
if output:
|
||||
return ref.node_id, output
|
||||
return None
|
||||
|
||||
|
||||
@@ -349,18 +275,13 @@ __all__ = [
|
||||
"MENTION_PATTERN",
|
||||
"NODE_JOB_PROMPT_ALLOWED_KINDS",
|
||||
"SOUL_PROMPT_ALLOWED_KINDS",
|
||||
"WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES",
|
||||
"MentionKind",
|
||||
"MentionResolver",
|
||||
"PromptMention",
|
||||
"build_node_job_mention_resolver",
|
||||
"build_soul_mention_resolver",
|
||||
"expand_prompt_mentions",
|
||||
"extract_workflow_node_output_selectors",
|
||||
"extract_workflow_variable_selectors",
|
||||
"find_malformed_mention_markers",
|
||||
"normalize_previous_node_output_selector",
|
||||
"parse_prompt_mentions",
|
||||
"scrub_mention_markers",
|
||||
"workflow_previous_node_output_refs_from_selectors",
|
||||
]
|
||||
|
||||
@@ -3,14 +3,11 @@ from typing import Any, TypedDict
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from constants.model_template import default_app_templates
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import to_timestamp
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -24,7 +21,7 @@ from models.agent import (
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import AppStatus, ConversationFromSource, ConversationStatus
|
||||
from models.model import App, AppMode, AppModelConfig, Conversation, IconType
|
||||
from models.model import App, AppMode, Conversation, IconType
|
||||
from models.workflow import Workflow
|
||||
from services.agent.agent_soul_state import agent_soul_has_model
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
@@ -86,7 +83,7 @@ class AgentRosterService:
|
||||
agent: Agent,
|
||||
active_version: AgentConfigSnapshot | None = None,
|
||||
published_references: list[AgentReferencingWorkflow] | None = None,
|
||||
active_config_is_published: bool | None = None,
|
||||
active_config_is_published: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
published_references = published_references or []
|
||||
return {
|
||||
@@ -101,16 +98,12 @@ class AgentRosterService:
|
||||
"scope": agent.scope.value,
|
||||
"source": agent.source.value,
|
||||
"app_id": agent.app_id,
|
||||
"backing_app_id": agent.backing_app_id,
|
||||
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
|
||||
"debug_conversation_id": None,
|
||||
"workflow_id": agent.workflow_id,
|
||||
"workflow_node_id": agent.workflow_node_id,
|
||||
"active_config_snapshot_id": agent.active_config_snapshot_id,
|
||||
"active_config_snapshot": AgentRosterService.serialize_version(active_version) if active_version else None,
|
||||
"active_config_is_published": agent.active_config_is_published
|
||||
if active_config_is_published is None
|
||||
else active_config_is_published,
|
||||
"active_config_is_published": active_config_is_published,
|
||||
"status": agent.status.value,
|
||||
"created_by": agent.created_by,
|
||||
"updated_by": agent.updated_by,
|
||||
@@ -328,7 +321,6 @@ class AgentRosterService:
|
||||
self._session.add(revision)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.active_config_is_published = True
|
||||
|
||||
try:
|
||||
self._session.commit()
|
||||
@@ -371,7 +363,6 @@ class AgentRosterService:
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id=app_id,
|
||||
backing_app_id=app_id,
|
||||
created_by=account_id,
|
||||
updated_by=account_id,
|
||||
)
|
||||
@@ -403,58 +394,10 @@ class AgentRosterService:
|
||||
self._session.add(revision)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(AgentSoulConfig())
|
||||
agent.active_config_is_published = False
|
||||
self._session.flush()
|
||||
self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id)
|
||||
return agent
|
||||
|
||||
def create_hidden_backing_app_for_workflow_agent(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str | None,
|
||||
name: str,
|
||||
description: str = "",
|
||||
icon_type: Any = None,
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
) -> App:
|
||||
"""Create an internal Agent App used only to back a workflow-only Agent.
|
||||
|
||||
This deliberately bypasses AppService.create_app because that public
|
||||
creation path also creates a roster Agent. Inline Agents need App runtime
|
||||
infrastructure for chat/logs/monitoring, but must stay hidden from the
|
||||
workspace Agent Roster until explicitly saved to roster.
|
||||
"""
|
||||
|
||||
app_template = dict(default_app_templates[AppMode.AGENT]["app"])
|
||||
app = App(**app_template)
|
||||
app.name = name
|
||||
app.description = description or ""
|
||||
app.mode = AppMode.AGENT
|
||||
normalized_icon_type = self._normalize_app_icon_type(icon_type)
|
||||
app.icon_type = IconType(normalized_icon_type) if normalized_icon_type else IconType.EMOJI
|
||||
app.icon = icon
|
||||
app.icon_background = icon_background
|
||||
app.tenant_id = tenant_id
|
||||
app.enable_site = False
|
||||
app.enable_api = False
|
||||
app.api_rph = 0
|
||||
app.api_rpm = 0
|
||||
app.max_active_requests = None
|
||||
app.created_by = account_id
|
||||
app.maintainer = account_id
|
||||
app.updated_by = account_id
|
||||
self._session.add(app)
|
||||
self._session.flush()
|
||||
|
||||
app_model_config = AppModelConfig(app_id=app.id, created_by=account_id, updated_by=account_id)
|
||||
self._session.add(app_model_config)
|
||||
self._session.flush()
|
||||
app.app_model_config_id = app_model_config.id
|
||||
self._session.flush()
|
||||
return app
|
||||
|
||||
def _create_agent_app_debug_conversation(self, *, app_id: str, account_id: str) -> str:
|
||||
"""Create one console debug conversation for an Agent App editor."""
|
||||
|
||||
@@ -480,31 +423,8 @@ class AgentRosterService:
|
||||
self._session.flush()
|
||||
return conversation.id
|
||||
|
||||
@staticmethod
|
||||
def runtime_backing_app_id(agent: Agent) -> str | None:
|
||||
"""Return the App id that backs Agent runtime chat/log/monitoring."""
|
||||
|
||||
return agent.backing_app_id or agent.app_id
|
||||
|
||||
def _ensure_workflow_agent_backing_app(self, *, agent: Agent, account_id: str | None) -> str | None:
|
||||
if agent.scope != AgentScope.WORKFLOW_ONLY or agent.backing_app_id:
|
||||
return self.runtime_backing_app_id(agent)
|
||||
backing_app = self.create_hidden_backing_app_for_workflow_agent(
|
||||
tenant_id=agent.tenant_id,
|
||||
account_id=account_id or agent.updated_by or agent.created_by,
|
||||
name=agent.name,
|
||||
description=agent.description,
|
||||
icon_type=agent.icon_type,
|
||||
icon=agent.icon,
|
||||
icon_background=agent.icon_background,
|
||||
)
|
||||
agent.backing_app_id = backing_app.id
|
||||
self._session.flush()
|
||||
return backing_app.id
|
||||
|
||||
def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str:
|
||||
backing_app_id = self._ensure_workflow_agent_backing_app(agent=agent, account_id=account_id)
|
||||
if not backing_app_id:
|
||||
if not agent.app_id:
|
||||
raise AgentNotFoundError()
|
||||
|
||||
mapping = self._session.scalar(
|
||||
@@ -518,7 +438,7 @@ class AgentRosterService:
|
||||
conversation_id = self._session.scalar(
|
||||
select(Conversation.id).where(
|
||||
Conversation.id == mapping.conversation_id,
|
||||
Conversation.app_id == backing_app_id,
|
||||
Conversation.app_id == agent.app_id,
|
||||
Conversation.from_source == ConversationFromSource.CONSOLE,
|
||||
Conversation.from_account_id == account_id,
|
||||
Conversation.is_deleted.is_(False),
|
||||
@@ -528,22 +448,21 @@ class AgentRosterService:
|
||||
return conversation_id
|
||||
|
||||
mapping.conversation_id = self._create_agent_app_debug_conversation(
|
||||
app_id=backing_app_id,
|
||||
app_id=agent.app_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
mapping.app_id = backing_app_id
|
||||
self._session.flush()
|
||||
return mapping.conversation_id
|
||||
|
||||
conversation_id = self._create_agent_app_debug_conversation(
|
||||
app_id=backing_app_id,
|
||||
app_id=agent.app_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
self._session.add(
|
||||
AgentDebugConversation(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
app_id=backing_app_id,
|
||||
app_id=agent.app_id,
|
||||
account_id=account_id,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
@@ -560,6 +479,8 @@ class AgentRosterService:
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
@@ -580,20 +501,16 @@ class AgentRosterService:
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
backing_app_id = self._ensure_workflow_agent_backing_app(
|
||||
agent=agent,
|
||||
account_id=agent.updated_by or agent.created_by,
|
||||
)
|
||||
if not backing_app_id:
|
||||
if agent is None or not agent.app_id:
|
||||
raise AgentNotFoundError()
|
||||
|
||||
conversation_id = self._create_agent_app_debug_conversation(
|
||||
app_id=backing_app_id,
|
||||
app_id=agent.app_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
mapping = self._session.scalar(
|
||||
@@ -608,13 +525,13 @@ class AgentRosterService:
|
||||
AgentDebugConversation(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
app_id=backing_app_id,
|
||||
app_id=agent.app_id,
|
||||
account_id=account_id,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
mapping.app_id = backing_app_id
|
||||
mapping.app_id = agent.app_id
|
||||
mapping.conversation_id = conversation_id
|
||||
self._session.flush()
|
||||
if commit:
|
||||
@@ -629,7 +546,11 @@ class AgentRosterService:
|
||||
conversation_ids_by_agent_id: dict[str, str] = {}
|
||||
changed = False
|
||||
for agent in agents:
|
||||
if agent.tenant_id != tenant_id or agent.status != AgentStatus.ACTIVE:
|
||||
if (
|
||||
agent.tenant_id != tenant_id
|
||||
or agent.scope != AgentScope.ROSTER
|
||||
or agent.source != AgentSource.AGENT_APP
|
||||
):
|
||||
continue
|
||||
conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation(
|
||||
agent=agent,
|
||||
@@ -653,7 +574,7 @@ class AgentRosterService:
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
return {agent.app_id: agent for agent in agents if agent.app_id and agent.id}
|
||||
return {agent.app_id: agent for agent in agents if agent.app_id}
|
||||
|
||||
def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None:
|
||||
"""Return the roster Agent that backs the given Agent App, if any."""
|
||||
@@ -704,59 +625,6 @@ class AgentRosterService:
|
||||
raise AgentNotFoundError()
|
||||
return app
|
||||
|
||||
def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App:
|
||||
"""Resolve the App that backs an Agent runtime surface.
|
||||
|
||||
Roster Agents use their public Agent App. Workflow-only Agents use a
|
||||
hidden Agent App stored in ``backing_app_id`` so console chat/logs can
|
||||
reuse the app runtime without exposing the resource in workspace app
|
||||
lists.
|
||||
"""
|
||||
|
||||
agent = self._session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
or_(
|
||||
and_(Agent.scope == AgentScope.ROSTER, Agent.source == AgentSource.AGENT_APP),
|
||||
and_(
|
||||
Agent.scope == AgentScope.WORKFLOW_ONLY,
|
||||
Agent.source == AgentSource.WORKFLOW,
|
||||
Agent.workflow_id.is_not(None),
|
||||
Agent.workflow_node_id.is_not(None),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
should_commit_backing_app = agent.scope == AgentScope.WORKFLOW_ONLY and not agent.backing_app_id
|
||||
backing_app_id = self._ensure_workflow_agent_backing_app(
|
||||
agent=agent,
|
||||
account_id=agent.updated_by or agent.created_by,
|
||||
)
|
||||
if not backing_app_id:
|
||||
raise AgentNotFoundError()
|
||||
if should_commit_backing_app:
|
||||
self._session.commit()
|
||||
|
||||
app = self._session.scalar(
|
||||
select(App)
|
||||
.where(
|
||||
App.tenant_id == tenant_id,
|
||||
App.id == backing_app_id,
|
||||
App.mode == AppMode.AGENT,
|
||||
App.status == AppStatus.NORMAL,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if app is None:
|
||||
raise AgentNotFoundError()
|
||||
return app
|
||||
|
||||
def duplicate_agent_app(
|
||||
self,
|
||||
*,
|
||||
@@ -824,10 +692,10 @@ class AgentRosterService:
|
||||
return target_app
|
||||
|
||||
@staticmethod
|
||||
def _normalize_app_icon_type(icon_type: Any | None) -> str | None:
|
||||
def _normalize_app_icon_type(icon_type: IconType | str | None) -> str | None:
|
||||
if icon_type is None:
|
||||
return None
|
||||
if isinstance(icon_type, IconType) or hasattr(icon_type, "value"):
|
||||
if isinstance(icon_type, IconType):
|
||||
return icon_type.value
|
||||
return icon_type
|
||||
|
||||
@@ -869,7 +737,6 @@ class AgentRosterService:
|
||||
target_version.version_note = source_version.version_note
|
||||
target_version.created_by = account_id
|
||||
target_agent.active_config_has_model = agent_soul_has_model(target_version.config_snapshot)
|
||||
target_agent.active_config_is_published = source_agent.active_config_is_published
|
||||
target_agent.updated_by = account_id
|
||||
|
||||
def _next_duplicate_agent_name(self, *, tenant_id: str, base_name: str) -> str:
|
||||
@@ -969,7 +836,6 @@ class AgentRosterService:
|
||||
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
|
||||
if agent.source == AgentSource.AGENT_APP:
|
||||
return {
|
||||
AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
@@ -983,27 +849,16 @@ class AgentRosterService:
|
||||
}
|
||||
|
||||
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
|
||||
"""Return whether the normal shared draft has been published into the active snapshot."""
|
||||
"""Return whether the Agent's current active snapshot is a visible published version."""
|
||||
return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get(
|
||||
agent.id,
|
||||
False,
|
||||
)
|
||||
|
||||
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
|
||||
"""Return each Agent's stored normal-draft publish state.
|
||||
|
||||
The flag is maintained by write paths against the normal shared draft:
|
||||
saves compare the draft content with the active snapshot, while publish
|
||||
and version creation paths mark the new active snapshot clean.
|
||||
User-scoped debug drafts intentionally do not affect this state.
|
||||
"""
|
||||
agents = [agent for agent in agents if agent.id]
|
||||
if not agents:
|
||||
return {}
|
||||
|
||||
return {
|
||||
agent.id: bool(agent.active_config_snapshot_id and agent.active_config_is_published) for agent in agents
|
||||
}
|
||||
"""Return publish-state flags for the active config snapshots of the given Agents."""
|
||||
published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents)
|
||||
return {agent.id: agent.id in published_agent_ids for agent in agents}
|
||||
|
||||
def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
|
||||
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
|
||||
@@ -1102,38 +957,26 @@ class AgentRosterService:
|
||||
raise AgentVersionNotFoundError()
|
||||
|
||||
version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
|
||||
draft = self._session.scalar(
|
||||
select(AgentConfigDraft)
|
||||
.where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent_id,
|
||||
AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT,
|
||||
AgentConfigDraft.account_id.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if draft is None:
|
||||
draft = AgentConfigDraft(
|
||||
if agent.active_config_snapshot_id == version.id:
|
||||
return {"result": "success", "active_config_snapshot_id": version.id}
|
||||
|
||||
previous_snapshot_id = agent.active_config_snapshot_id
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(version.config_snapshot)
|
||||
agent.updated_by = account_id
|
||||
self._session.add(
|
||||
AgentConfigRevision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
previous_snapshot_id=previous_snapshot_id,
|
||||
current_snapshot_id=version.id,
|
||||
revision=self._next_revision(tenant_id=tenant_id, agent_id=agent_id),
|
||||
operation=AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
created_by=account_id,
|
||||
)
|
||||
self._session.add(draft)
|
||||
draft.base_snapshot_id = version.id
|
||||
draft.config_snapshot = AgentSoulConfig.model_validate(version.config_snapshot_dict)
|
||||
draft.updated_by = account_id
|
||||
agent.active_config_is_published = version.id == agent.active_config_snapshot_id
|
||||
agent.updated_by = account_id
|
||||
)
|
||||
self._session.commit()
|
||||
return {
|
||||
"result": "success",
|
||||
"active_config_snapshot_id": agent.active_config_snapshot_id or version.id,
|
||||
"draft_config_id": draft.id,
|
||||
"restored_version_id": version.id,
|
||||
}
|
||||
return {"result": "success", "active_config_snapshot_id": version.id}
|
||||
|
||||
def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent:
|
||||
stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id)
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"""Validate and normalize uploaded Skill packages for drive standardization.
|
||||
"""Validate + extract metadata from an uploaded Skill package (ENG-370).
|
||||
|
||||
A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entry
|
||||
file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``,
|
||||
followed by markdown instructions). This service validates the archive (extension,
|
||||
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields),
|
||||
normalizes retained member paths relative to the selected skill root, rebuilds
|
||||
canonical archive bytes, and returns normalized metadata together with the
|
||||
archive-root ``SKILL.md`` bytes.
|
||||
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields) and
|
||||
extracts a manifest consumed by drive standardization.
|
||||
|
||||
It does NOT execute or load the skill — the agent backend owns execution. It also
|
||||
does not persist anything into Agent Soul or bind anything to config versions;
|
||||
``SkillStandardizeService`` consumes the normalized package and commits the
|
||||
canonical drive rows instead.
|
||||
``SkillStandardizeService`` consumes the manifest and commits the canonical drive
|
||||
rows instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,7 +19,6 @@ import io
|
||||
import posixpath
|
||||
import re
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
@@ -61,69 +58,10 @@ class SkillManifest(BaseModel):
|
||||
hash: str # sha256 of the archive bytes
|
||||
|
||||
|
||||
class NormalizedSkillPackage(BaseModel):
|
||||
"""Canonical skill package bytes and metadata ready to store in agent drive."""
|
||||
|
||||
manifest: SkillManifest
|
||||
archive_bytes: bytes
|
||||
skill_md_bytes: bytes
|
||||
strip_prefix: str | None
|
||||
|
||||
|
||||
class SkillPackageService:
|
||||
"""Validate Skill archives and produce the normalized package stored in drive."""
|
||||
"""Validate Skill archives and extract their manifest."""
|
||||
|
||||
def validate_and_normalize(self, *, content: bytes, filename: str) -> NormalizedSkillPackage:
|
||||
"""Return the canonical drive package for an uploaded skill archive.
|
||||
|
||||
The shallowest ``SKILL.md`` defines the skill root. When exactly one
|
||||
depth-2 ``<folder>/SKILL.md`` exists, normalization strips that top-level
|
||||
folder and silently discards all members outside it, including nested
|
||||
foreign paths. When that unique depth-2 condition does not apply, files
|
||||
outside the selected skill root still raise ``files_outside_skill_root``.
|
||||
The returned manifest is normalized to archive-root ``SKILL.md`` and its
|
||||
hash describes the rebuilt archive bytes. Member read/decompression
|
||||
failures while consuming the archive are mapped to ``invalid_archive``.
|
||||
"""
|
||||
archive = self._open_archive(content=content, filename=filename)
|
||||
with archive:
|
||||
members = self._collect_file_members(archive)
|
||||
member_paths = [safe_path for _, safe_path in members]
|
||||
entry_path = self._find_skill_md(member_paths)
|
||||
strip_prefix = self._skill_root_prefix(entry_path)
|
||||
normalized_members = self._normalize_members(
|
||||
members=members,
|
||||
skill_root_prefix=strip_prefix,
|
||||
ignore_outside_selected_root=self._can_strip_single_top_level_folder(
|
||||
paths=member_paths, entry_path=entry_path
|
||||
),
|
||||
)
|
||||
skill_md_member = normalized_members[_SKILL_MD_NAME]
|
||||
self._validate_skill_md_size(skill_md_member)
|
||||
skill_md_bytes = self._read_member_bytes_from_archive(archive, member_info=skill_md_member)
|
||||
skill_md = self._decode_skill_md(skill_md_bytes)
|
||||
normalized_archive_bytes = self._build_normalized_archive(
|
||||
archive=archive, normalized_members=normalized_members
|
||||
)
|
||||
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
|
||||
|
||||
name, description = self._parse_skill_md(skill_md)
|
||||
manifest = SkillManifest(
|
||||
name=name,
|
||||
description=description,
|
||||
entry_path=_SKILL_MD_NAME,
|
||||
files=sorted(normalized_members),
|
||||
size=normalized_size,
|
||||
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
|
||||
)
|
||||
return NormalizedSkillPackage(
|
||||
manifest=manifest,
|
||||
archive_bytes=normalized_archive_bytes,
|
||||
skill_md_bytes=skill_md_bytes,
|
||||
strip_prefix=strip_prefix,
|
||||
)
|
||||
|
||||
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
|
||||
def validate_and_extract(self, *, content: bytes, filename: str) -> SkillManifest:
|
||||
self._check_extension(filename)
|
||||
if not content:
|
||||
raise SkillPackageError("empty_archive", "skill archive is empty", status_code=400)
|
||||
@@ -131,90 +69,52 @@ class SkillPackageService:
|
||||
raise SkillPackageError("archive_too_large", "skill archive exceeds size limit", status_code=400)
|
||||
|
||||
try:
|
||||
return zipfile.ZipFile(io.BytesIO(content))
|
||||
archive = zipfile.ZipFile(io.BytesIO(content))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
|
||||
|
||||
def _collect_file_members(self, archive: zipfile.ZipFile) -> list[tuple[zipfile.ZipInfo, str]]:
|
||||
infos = [info for info in archive.infolist() if not info.is_dir()]
|
||||
if len(infos) > _MAX_ENTRIES:
|
||||
raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400)
|
||||
with archive:
|
||||
infos = [info for info in archive.infolist() if not info.is_dir()]
|
||||
if len(infos) > _MAX_ENTRIES:
|
||||
raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400)
|
||||
|
||||
members: list[tuple[zipfile.ZipInfo, str]] = []
|
||||
total_uncompressed = 0
|
||||
for info in infos:
|
||||
members.append((info, self._safe_member_path(info.filename)))
|
||||
total_uncompressed += max(info.file_size, 0)
|
||||
if total_uncompressed > _MAX_UNCOMPRESSED_BYTES:
|
||||
raise SkillPackageError(
|
||||
"archive_too_large",
|
||||
"skill archive uncompressed size exceeds limit",
|
||||
status_code=400,
|
||||
)
|
||||
return members
|
||||
|
||||
@staticmethod
|
||||
def _skill_root_prefix(entry_path: str) -> str | None:
|
||||
skill_root = posixpath.dirname(entry_path)
|
||||
if not skill_root:
|
||||
return None
|
||||
return f"{skill_root}/"
|
||||
|
||||
def _normalize_members(
|
||||
self,
|
||||
*,
|
||||
members: list[tuple[zipfile.ZipInfo, str]],
|
||||
skill_root_prefix: str | None,
|
||||
ignore_outside_selected_root: bool = False,
|
||||
) -> dict[str, zipfile.ZipInfo]:
|
||||
normalized_members: dict[str, zipfile.ZipInfo] = {}
|
||||
for info, safe_path in members:
|
||||
if skill_root_prefix is not None:
|
||||
if not safe_path.startswith(skill_root_prefix):
|
||||
if ignore_outside_selected_root:
|
||||
continue
|
||||
raise SkillPackageError(
|
||||
"files_outside_skill_root",
|
||||
"skill archive contains files outside the selected skill root",
|
||||
status_code=400,
|
||||
)
|
||||
normalized_path = safe_path.removeprefix(skill_root_prefix)
|
||||
else:
|
||||
normalized_path = safe_path
|
||||
|
||||
if (
|
||||
not normalized_path
|
||||
or normalized_path in {".", ".."}
|
||||
or normalized_path.startswith("/")
|
||||
or "\\" in normalized_path
|
||||
):
|
||||
raise SkillPackageError("unsafe_path", "skill archive contains an unsafe path", status_code=400)
|
||||
if normalized_path in normalized_members:
|
||||
safe_paths: list[str] = []
|
||||
total_uncompressed = 0
|
||||
for info in infos:
|
||||
safe_paths.append(self._safe_member_path(info.filename))
|
||||
total_uncompressed += max(info.file_size, 0)
|
||||
if total_uncompressed > _MAX_UNCOMPRESSED_BYTES:
|
||||
raise SkillPackageError(
|
||||
"duplicate_member_path",
|
||||
"skill archive contains duplicate normalized paths",
|
||||
status_code=400,
|
||||
"archive_too_large", "skill archive uncompressed size exceeds limit", status_code=400
|
||||
)
|
||||
normalized_members[normalized_path] = info
|
||||
|
||||
if _SKILL_MD_NAME not in normalized_members:
|
||||
raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400)
|
||||
return normalized_members
|
||||
entry_path = self._find_skill_md(safe_paths)
|
||||
skill_md = self._read_skill_md(archive, entry_path)
|
||||
|
||||
def _build_normalized_archive(
|
||||
self,
|
||||
*,
|
||||
archive: zipfile.ZipFile,
|
||||
normalized_members: dict[str, zipfile.ZipInfo],
|
||||
) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as normalized_archive:
|
||||
for normalized_path in sorted(normalized_members):
|
||||
normalized_archive.writestr(
|
||||
normalized_path,
|
||||
self._read_member_bytes_from_archive(archive, member_info=normalized_members[normalized_path]),
|
||||
)
|
||||
return output.getvalue()
|
||||
name, description = self._parse_skill_md(skill_md)
|
||||
return SkillManifest(
|
||||
name=name,
|
||||
description=description,
|
||||
entry_path=entry_path,
|
||||
files=sorted(safe_paths),
|
||||
size=total_uncompressed,
|
||||
hash=hashlib.sha256(content).hexdigest(),
|
||||
)
|
||||
|
||||
def read_member_bytes(self, *, content: bytes, member_path: str) -> bytes:
|
||||
"""Read a single archive member's bytes (used by standardization, ENG-594)."""
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(content))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
|
||||
with archive:
|
||||
member = next(
|
||||
(info for info in archive.infolist() if posixpath.normpath(info.filename) == member_path),
|
||||
None,
|
||||
)
|
||||
if member is None:
|
||||
raise SkillPackageError("member_not_found", f"{member_path} not found in archive", status_code=400)
|
||||
return archive.read(member)
|
||||
|
||||
@staticmethod
|
||||
def _check_extension(filename: str) -> None:
|
||||
@@ -245,26 +145,17 @@ class SkillPackageService:
|
||||
return min(candidates, key=lambda p: (p.count("/"), len(p)))
|
||||
|
||||
@staticmethod
|
||||
def _can_strip_single_top_level_folder(*, paths: list[str], entry_path: str) -> bool:
|
||||
if entry_path.count("/") != 1:
|
||||
return False
|
||||
candidates = [path for path in paths if path.count("/") == 1 and posixpath.basename(path) == _SKILL_MD_NAME]
|
||||
return len(candidates) == 1 and candidates[0] == entry_path
|
||||
|
||||
@staticmethod
|
||||
def _read_member_bytes_from_archive(archive: zipfile.ZipFile, *, member_info: zipfile.ZipInfo) -> bytes:
|
||||
try:
|
||||
return archive.read(member_info)
|
||||
except (zipfile.BadZipFile, EOFError, OSError, RuntimeError, ValueError, zlib.error) as exc:
|
||||
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
|
||||
|
||||
@staticmethod
|
||||
def _validate_skill_md_size(member_info: zipfile.ZipInfo) -> None:
|
||||
if member_info.file_size > _MAX_SKILL_MD_BYTES:
|
||||
def _read_skill_md(archive: zipfile.ZipFile, entry_path: str) -> str:
|
||||
# Look the member up by its original name (normpath may differ from the stored name).
|
||||
member = next(
|
||||
(info for info in archive.infolist() if posixpath.normpath(info.filename) == entry_path),
|
||||
None,
|
||||
)
|
||||
if member is None:
|
||||
raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400)
|
||||
if member.file_size > _MAX_SKILL_MD_BYTES:
|
||||
raise SkillPackageError("skill_md_too_large", "SKILL.md exceeds size limit", status_code=400)
|
||||
|
||||
@staticmethod
|
||||
def _decode_skill_md(raw: bytes) -> str:
|
||||
raw = archive.read(member)
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
@@ -302,4 +193,4 @@ class SkillPackageService:
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
|
||||
|
||||
__all__ = ["NormalizedSkillPackage", "SkillManifest", "SkillPackageError", "SkillPackageService"]
|
||||
__all__ = ["SkillManifest", "SkillPackageError", "SkillPackageService"]
|
||||
|
||||
@@ -7,14 +7,19 @@ to the agent drive (Agent Files §5.4 / §4):
|
||||
* ``<slug>/.DIFY-SKILL-FULL.zip`` — the full archive, kept only to restore the
|
||||
complete skill contents.
|
||||
|
||||
The archive's member list is stored in skill metadata and resolved lazily for
|
||||
inspect/preview/runtime. Upload must not eagerly materialize every archive member
|
||||
as a separate ToolFile; small archives with many files would otherwise perform
|
||||
hundreds of storage writes and DB commits inside the request.
|
||||
Both are stored as ``ToolFile`` records and bound via ``AgentDriveService.commit``
|
||||
with ``value_owned_by_drive=True`` (the drive owns their lifecycle). The returned
|
||||
payload is the slim drive-derived skill DTO the UI needs to work with the drive
|
||||
catalog — ``name``, ``description``, ``path``, ``skill_md_key``, and
|
||||
``archive_key`` — plus the extracted manifest for upload feedback. The console
|
||||
``/skills/upload`` endpoints delegate to this service so "upload" now always means
|
||||
drive-backed skill normalization rather than Agent Soul binding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import posixpath
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
@@ -33,11 +38,7 @@ def slugify_skill_name(name: str) -> str:
|
||||
|
||||
|
||||
class SkillStandardizeService:
|
||||
"""Persist a normalized skill package into drive-owned files for one agent.
|
||||
|
||||
Instances are intentionally stateful: ``standardize()`` updates
|
||||
``last_committed_items`` with the drive commit result for the most recent call.
|
||||
"""
|
||||
"""Validate + standardize a Skill package into a per-agent drive upload result."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -49,7 +50,6 @@ class SkillStandardizeService:
|
||||
self._package = package_service or SkillPackageService()
|
||||
self._drive = drive_service or AgentDriveService()
|
||||
self._tool_files = tool_file_manager or ToolFileManager()
|
||||
self.last_committed_items: list[dict[str, Any]] = []
|
||||
|
||||
def standardize(
|
||||
self,
|
||||
@@ -60,23 +60,17 @@ class SkillStandardizeService:
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Create two ToolFiles, commit two drive-owned keys, and return skill metadata.
|
||||
|
||||
This writes ``<slug>/SKILL.md`` and ``<slug>/.DIFY-SKILL-FULL.zip``,
|
||||
stores the drive commit rows in ``last_committed_items``, and returns the
|
||||
console response shape ``{"skill": ..., "manifest": ...}``.
|
||||
"""
|
||||
package = self._package.validate_and_normalize(content=content, filename=filename)
|
||||
manifest = package.manifest
|
||||
manifest = self._package.validate_and_extract(content=content, filename=filename)
|
||||
skill_md_bytes = self._package.read_member_bytes(content=content, member_path=manifest.entry_path)
|
||||
slug = slugify_skill_name(manifest.name)
|
||||
|
||||
# Drive-owned files: canonical SKILL.md and the full archive. The
|
||||
# archive member tree is preserved in metadata and resolved lazily.
|
||||
# Drive-owned files: canonical SKILL.md, every inspectable archive file,
|
||||
# and the full archive for future restore/export.
|
||||
md_tool_file = self._tool_files.create_file_by_raw(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_binary=package.skill_md_bytes,
|
||||
file_binary=skill_md_bytes,
|
||||
mimetype="text/markdown",
|
||||
filename=_SKILL_MD_NAME,
|
||||
)
|
||||
@@ -84,14 +78,38 @@ class SkillStandardizeService:
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_binary=package.archive_bytes,
|
||||
file_binary=content,
|
||||
mimetype="application/zip",
|
||||
filename=_FULL_ARCHIVE_NAME,
|
||||
)
|
||||
|
||||
skill_md_key = f"{slug}/{_SKILL_MD_NAME}"
|
||||
archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}"
|
||||
committed_items = self._drive.commit(
|
||||
member_items: list[DriveCommitItem] = []
|
||||
for member_path in sorted(set(manifest.files)):
|
||||
member_key = f"{slug}/{member_path}"
|
||||
if member_key in {skill_md_key, archive_key}:
|
||||
continue
|
||||
|
||||
member_bytes = self._package.read_member_bytes(content=content, member_path=member_path)
|
||||
mimetype = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
|
||||
member_tool_file = self._tool_files.create_file_by_raw(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_binary=member_bytes,
|
||||
mimetype=mimetype,
|
||||
filename=posixpath.basename(member_path),
|
||||
)
|
||||
member_items.append(
|
||||
DriveCommitItem(
|
||||
key=member_key,
|
||||
file_ref=DriveFileRef(kind="tool_file", id=member_tool_file.id),
|
||||
value_owned_by_drive=True,
|
||||
)
|
||||
)
|
||||
|
||||
self._drive.commit(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
@@ -112,17 +130,23 @@ class SkillStandardizeService:
|
||||
file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id),
|
||||
value_owned_by_drive=True,
|
||||
),
|
||||
*member_items,
|
||||
],
|
||||
)
|
||||
self.last_committed_items = committed_items
|
||||
|
||||
drive_skill = next(
|
||||
skill
|
||||
for skill in self._drive.list_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if skill["skill_md_key"] == skill_md_key
|
||||
)
|
||||
|
||||
return {
|
||||
"skill": {
|
||||
"name": manifest.name,
|
||||
"description": manifest.description,
|
||||
"path": slug,
|
||||
"skill_md_key": skill_md_key,
|
||||
"archive_key": archive_key,
|
||||
"name": drive_skill["name"],
|
||||
"description": drive_skill["description"],
|
||||
"path": drive_skill["path"],
|
||||
"skill_md_key": drive_skill["skill_md_key"],
|
||||
"archive_key": drive_skill["archive_key"],
|
||||
},
|
||||
"manifest": manifest.model_dump(),
|
||||
}
|
||||
|
||||
@@ -18,18 +18,9 @@ from models.agent import (
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
WorkflowNodeJobConfig,
|
||||
WorkflowPreviousNodeOutputRef,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig
|
||||
from models.workflow import Workflow
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.prompt_mentions import (
|
||||
extract_workflow_node_output_selectors,
|
||||
workflow_previous_node_output_refs_from_selectors,
|
||||
)
|
||||
from services.entities.agent_entities import (
|
||||
ComposerSavePayload,
|
||||
ComposerSaveStrategy,
|
||||
@@ -48,10 +39,10 @@ class WorkflowAgentPublishService:
|
||||
|
||||
@classmethod
|
||||
def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]:
|
||||
"""Return draft graph with persisted Agent binding fields projected into node data.
|
||||
"""Return draft graph with persisted Agent node job config projected into node data.
|
||||
|
||||
Workflow draft graph is the front-end's editing source of truth, while
|
||||
runtime/publish reads WorkflowAgentNodeBinding. This
|
||||
runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This
|
||||
response-only projection keeps reads aligned without writing binding
|
||||
details back into the stored graph JSON.
|
||||
"""
|
||||
@@ -73,18 +64,6 @@ class WorkflowAgentPublishService:
|
||||
node_data = agent_nodes.get(binding.node_id)
|
||||
if not isinstance(node_data, dict):
|
||||
continue
|
||||
graph_binding = node_data.get(cls._AGENT_BINDING_KEY)
|
||||
is_pending_inline_graph_binding = (
|
||||
isinstance(graph_binding, Mapping)
|
||||
and graph_binding.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value
|
||||
and (not graph_binding.get("agent_id") or not graph_binding.get("current_snapshot_id"))
|
||||
)
|
||||
if not is_pending_inline_graph_binding or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
|
||||
node_data[cls._AGENT_BINDING_KEY] = {
|
||||
"binding_type": binding.binding_type.value,
|
||||
"agent_id": binding.agent_id,
|
||||
"current_snapshot_id": binding.current_snapshot_id,
|
||||
}
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
if node_job.workflow_prompt is not None:
|
||||
node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt
|
||||
@@ -252,10 +231,6 @@ class WorkflowAgentPublishService:
|
||||
continue
|
||||
if not isinstance(binding_payload, Mapping):
|
||||
raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.")
|
||||
if binding_payload.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value and (
|
||||
not binding_payload.get("agent_id") or not binding_payload.get("current_snapshot_id")
|
||||
):
|
||||
continue
|
||||
cls._sync_agent_binding_for_node(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
@@ -434,7 +409,6 @@ class WorkflowAgentPublishService:
|
||||
agent_task = node_data.get(cls._AGENT_TASK_KEY)
|
||||
if isinstance(agent_task, str):
|
||||
node_job.workflow_prompt = agent_task
|
||||
node_job.previous_node_output_refs = cls._previous_node_output_refs_from_prompt(agent_task)
|
||||
|
||||
declared_outputs_payload = node_data.get(cls._AGENT_DECLARED_OUTPUTS_KEY)
|
||||
if declared_outputs_payload is not None:
|
||||
@@ -449,11 +423,6 @@ class WorkflowAgentPublishService:
|
||||
|
||||
return node_job
|
||||
|
||||
@classmethod
|
||||
def _previous_node_output_refs_from_prompt(cls, prompt: str) -> list[WorkflowPreviousNodeOutputRef]:
|
||||
"""Derive persisted refs from the current frontend workflow markers only."""
|
||||
return workflow_previous_node_output_refs_from_selectors(extract_workflow_node_output_selectors(prompt))
|
||||
|
||||
@classmethod
|
||||
def copy_agent_node_bindings_to_published(
|
||||
cls,
|
||||
|
||||
@@ -19,18 +19,10 @@ ToolFile records (see ``AgentDriveFile``). This service is the control plane:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from typing import Any, Literal, TypedDict
|
||||
from urllib.parse import unquote
|
||||
|
||||
@@ -39,7 +31,6 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import DataError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.file_access.controller import DatabaseFileAccessController
|
||||
from core.db.session_factory import session_factory
|
||||
from extensions.ext_storage import storage
|
||||
@@ -55,7 +46,6 @@ _MAX_KEY_LENGTH = 512
|
||||
_DRIVE_REF_PREFIX = "agent-"
|
||||
_SKILL_MD_SUFFIX = "/SKILL.md"
|
||||
_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
|
||||
_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE = "agent-drive-archive-member"
|
||||
|
||||
|
||||
class AgentDriveError(Exception):
|
||||
@@ -375,7 +365,6 @@ class AgentDriveService:
|
||||
skill_md_key=skill_md_key,
|
||||
manifest_files=manifest_files,
|
||||
drive_keys=drive_keys,
|
||||
archive_available=catalog["archive_key"] in drive_keys if catalog["archive_key"] else False,
|
||||
)
|
||||
return {
|
||||
**catalog,
|
||||
@@ -609,7 +598,6 @@ class AgentDriveService:
|
||||
skill_md_key: str,
|
||||
manifest_files: list[str] | None,
|
||||
drive_keys: set[str],
|
||||
archive_available: bool = False,
|
||||
) -> tuple[list[AgentDriveSkillFileInfo], list[str]]:
|
||||
warnings: list[str] = []
|
||||
if manifest_files:
|
||||
@@ -629,14 +617,13 @@ class AgentDriveService:
|
||||
if path == _SKILL_ARCHIVE_NAME:
|
||||
continue
|
||||
drive_key = f"{skill_path}/{path}"
|
||||
available_in_drive = drive_key in drive_keys or (archive_available and path != _SKILL_ARCHIVE_NAME)
|
||||
files.append(
|
||||
{
|
||||
"path": path,
|
||||
"name": path.rsplit("/", 1)[-1],
|
||||
"type": "file",
|
||||
"drive_key": drive_key if available_in_drive else None,
|
||||
"available_in_drive": available_in_drive,
|
||||
"drive_key": drive_key if drive_key in drive_keys else None,
|
||||
"available_in_drive": drive_key in drive_keys,
|
||||
}
|
||||
)
|
||||
if "SKILL.md" not in {file["path"] for file in files}:
|
||||
@@ -857,209 +844,56 @@ class AgentDriveService:
|
||||
return row
|
||||
|
||||
def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str:
|
||||
return self._storage_key_for_ref(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
file_kind=row.file_kind,
|
||||
file_id=row.file_id,
|
||||
)
|
||||
|
||||
def _storage_key_for_ref(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
file_kind: AgentDriveFileKind,
|
||||
file_id: str,
|
||||
) -> str:
|
||||
if file_kind == AgentDriveFileKind.TOOL_FILE:
|
||||
tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id))
|
||||
if row.file_kind == AgentDriveFileKind.TOOL_FILE:
|
||||
tool_file = session.scalar(
|
||||
select(ToolFile).where(ToolFile.id == row.file_id, ToolFile.tenant_id == tenant_id)
|
||||
)
|
||||
if tool_file is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
|
||||
return tool_file.file_key
|
||||
upload_file = session.scalar(
|
||||
select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id)
|
||||
select(UploadFile).where(UploadFile.id == row.file_id, UploadFile.tenant_id == tenant_id)
|
||||
)
|
||||
if upload_file is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
|
||||
return upload_file.key
|
||||
|
||||
def _archive_member_for_key(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
) -> tuple[AgentDriveFile, str]:
|
||||
normalized_key = normalize_drive_key(key)
|
||||
if "/" not in normalized_key:
|
||||
raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
|
||||
skill_path, member_path = normalized_key.split("/", 1)
|
||||
if member_path in {_SKILL_ARCHIVE_NAME, ""}:
|
||||
raise AgentDriveError("drive_key_not_found", "no archive member for this key", status_code=404)
|
||||
|
||||
skill_md_key = f"{skill_path}{_SKILL_MD_SUFFIX}"
|
||||
skill_row = session.scalar(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
AgentDriveFile.key == skill_md_key,
|
||||
AgentDriveFile.is_skill.is_(True),
|
||||
)
|
||||
)
|
||||
if skill_row is None:
|
||||
raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
|
||||
metadata = self._parse_skill_metadata(skill_row.key, skill_row.skill_metadata)
|
||||
manifest_files = {normalize_drive_key(path) for path in (metadata.manifest_files or [])}
|
||||
if member_path not in manifest_files:
|
||||
raise AgentDriveError("drive_key_not_found", "archive member is not part of this skill", status_code=404)
|
||||
archive_row = session.scalar(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
AgentDriveFile.key == self._skill_archive_key(skill_md_key),
|
||||
)
|
||||
)
|
||||
if archive_row is None:
|
||||
raise AgentDriveError("drive_key_not_found", "skill archive is missing", status_code=404)
|
||||
return archive_row, member_path
|
||||
|
||||
def _load_archive_member_bytes(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
) -> bytes:
|
||||
member_path = normalize_drive_key(member_path)
|
||||
with session_factory.create_session() as session:
|
||||
storage_key = self._storage_key_for_ref(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
file_kind=archive_file_kind,
|
||||
file_id=archive_file_id,
|
||||
)
|
||||
archive_bytes = b"".join(storage.load_stream(storage_key))
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
|
||||
member = next(
|
||||
(
|
||||
info
|
||||
for info in archive.infolist()
|
||||
if not info.is_dir() and normalize_drive_key(info.filename) == member_path
|
||||
),
|
||||
None,
|
||||
)
|
||||
if member is None:
|
||||
raise AgentDriveError(
|
||||
"drive_key_not_found", "archive member is missing from the skill archive", status_code=404
|
||||
)
|
||||
return archive.read(member)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise AgentDriveError("invalid_skill_archive", "skill archive is not a valid zip", status_code=500) from exc
|
||||
|
||||
@classmethod
|
||||
def _preview_bytes(cls, *, key: str, size: int | None, payload: bytes) -> dict[str, Any]:
|
||||
truncated = len(payload) > cls.PREVIEW_MAX_BYTES
|
||||
sample = payload[: cls.PREVIEW_MAX_BYTES]
|
||||
if b"\x00" in sample:
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
try:
|
||||
text = sample.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
if truncated:
|
||||
try:
|
||||
text = sample[:-3].decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError:
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
else:
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": False, "text": text}
|
||||
|
||||
def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
|
||||
"""Truncated text preview of one drive value (binary-safe, never 500s on size)."""
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
|
||||
size = row.size
|
||||
response_key = row.key
|
||||
archive_ref: tuple[AgentDriveFile, str] | None = None
|
||||
except AgentDriveError:
|
||||
archive_ref = self._archive_member_for_key(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
)
|
||||
storage_key = None
|
||||
size = None
|
||||
response_key = normalize_drive_key(key)
|
||||
|
||||
if archive_ref is not None:
|
||||
archive_row, member_path = archive_ref
|
||||
payload = self._load_archive_member_bytes(
|
||||
tenant_id=tenant_id,
|
||||
archive_file_kind=archive_row.file_kind,
|
||||
archive_file_id=archive_row.file_id,
|
||||
member_path=member_path,
|
||||
)
|
||||
return self._preview_bytes(key=response_key, size=len(payload), payload=payload)
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
|
||||
size = row.size
|
||||
|
||||
data = bytearray()
|
||||
assert storage_key is not None
|
||||
for chunk in storage.load_stream(storage_key):
|
||||
data.extend(chunk)
|
||||
if len(data) > self.PREVIEW_MAX_BYTES:
|
||||
break
|
||||
return self._preview_bytes(key=response_key, size=size, payload=bytes(data))
|
||||
|
||||
def preview_archive_member_for_ref(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
) -> dict[str, Any]:
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = self._load_archive_member_bytes(
|
||||
tenant_id=tenant_id,
|
||||
archive_file_kind=archive_file_kind,
|
||||
archive_file_id=archive_file_id,
|
||||
member_path=member_path,
|
||||
)
|
||||
return self._preview_bytes(key=normalize_drive_key(key), size=len(payload), payload=payload)
|
||||
truncated = len(data) > self.PREVIEW_MAX_BYTES
|
||||
sample = bytes(data[: self.PREVIEW_MAX_BYTES])
|
||||
# Same semantics as the sandbox read endpoint: NUL or undecodable -> binary.
|
||||
if b"\x00" in sample:
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
try:
|
||||
text = sample.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
if truncated:
|
||||
# A multi-byte char may sit on the cut point; retry without the tail.
|
||||
try:
|
||||
text = sample[:-3].decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError:
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
else:
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text}
|
||||
|
||||
def download_url(self, *, tenant_id: str, agent_id: str, key: str) -> str:
|
||||
"""External signed URL for a browser download of one drive value."""
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
except AgentDriveError:
|
||||
archive_row, member_path = self._archive_member_for_key(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
)
|
||||
return self.sign_archive_member_url(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
archive_file_kind=archive_row.file_kind,
|
||||
archive_file_id=archive_row.file_id,
|
||||
member_path=member_path,
|
||||
for_external=True,
|
||||
as_attachment=True,
|
||||
)
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
url = self._resolve_download_url(
|
||||
tenant_id=tenant_id,
|
||||
file_kind=row.file_kind,
|
||||
@@ -1071,159 +905,6 @@ class AgentDriveService:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
|
||||
return url
|
||||
|
||||
def download_url_archive_member_for_ref(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
for_external: bool = True,
|
||||
) -> str:
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return self.sign_archive_member_url(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
archive_file_kind=archive_file_kind,
|
||||
archive_file_id=archive_file_id,
|
||||
member_path=member_path,
|
||||
for_external=for_external,
|
||||
as_attachment=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _secret_key() -> bytes:
|
||||
return dify_config.SECRET_KEY.encode()
|
||||
|
||||
@classmethod
|
||||
def _archive_member_signature_payload(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
) -> str:
|
||||
return "|".join(
|
||||
[
|
||||
_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE,
|
||||
tenant_id,
|
||||
agent_id,
|
||||
normalize_drive_key(key),
|
||||
archive_file_kind.value,
|
||||
archive_file_id,
|
||||
normalize_drive_key(member_path),
|
||||
timestamp,
|
||||
nonce,
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _sign_archive_member_payload(cls, payload: str) -> str:
|
||||
digest = hmac.new(cls._secret_key(), payload.encode(), hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode()
|
||||
|
||||
@classmethod
|
||||
def sign_archive_member_url(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
for_external: bool,
|
||||
as_attachment: bool = False,
|
||||
) -> str:
|
||||
base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = os.urandom(16).hex()
|
||||
payload = cls._archive_member_signature_payload(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
archive_file_kind=archive_file_kind,
|
||||
archive_file_id=archive_file_id,
|
||||
member_path=member_path,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
)
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"agent_id": agent_id,
|
||||
"key": normalize_drive_key(key),
|
||||
"archive_file_kind": archive_file_kind.value,
|
||||
"archive_file_id": archive_file_id,
|
||||
"member_path": normalize_drive_key(member_path),
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
"sign": cls._sign_archive_member_payload(payload),
|
||||
"as_attachment": str(as_attachment).lower(),
|
||||
}
|
||||
)
|
||||
return f"{base_url}/files/agent-drive/archive-member?{query}"
|
||||
|
||||
@classmethod
|
||||
def verify_archive_member_signature(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
sign: str,
|
||||
) -> bool:
|
||||
payload = cls._archive_member_signature_payload(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
archive_file_kind=archive_file_kind,
|
||||
archive_file_id=archive_file_id,
|
||||
member_path=member_path,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
)
|
||||
if sign != cls._sign_archive_member_payload(payload):
|
||||
return False
|
||||
current_time = int(time.time())
|
||||
return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
|
||||
|
||||
def load_archive_member_for_signed_request(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
archive_file_kind: AgentDriveFileKind,
|
||||
archive_file_id: str,
|
||||
member_path: str,
|
||||
) -> tuple[bytes, str, str]:
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = self._load_archive_member_bytes(
|
||||
tenant_id=tenant_id,
|
||||
archive_file_kind=archive_file_kind,
|
||||
archive_file_id=archive_file_id,
|
||||
member_path=member_path,
|
||||
)
|
||||
mime_type = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
|
||||
filename = normalize_drive_key(key).rsplit("/", 1)[-1]
|
||||
return payload, mime_type, filename
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDriveError",
|
||||
|
||||
@@ -96,17 +96,6 @@ class AppService:
|
||||
filters.append(App.mode == AppMode.AGENT_CHAT)
|
||||
elif params.mode == "agent":
|
||||
filters.append(App.mode == AppMode.AGENT)
|
||||
filters.append(
|
||||
sa.exists()
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == App.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.correlate(App)
|
||||
)
|
||||
elif params.mode == "all":
|
||||
filters.append(App.mode != AppMode.AGENT)
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ from typing import Any, Literal, NotRequired, TypedDict
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, scoped_session
|
||||
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
from core.helper.http_client_pooling import get_pooled_http_client
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.helper import RateLimiter
|
||||
from models import Account, TenantAccountJoin, TenantAccountRole
|
||||
@@ -363,10 +363,10 @@ class BillingService:
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def is_tenant_owner_or_admin(session: Session | scoped_session, current_user: Account):
|
||||
def is_tenant_owner_or_admin(current_user: Account):
|
||||
tenant_id = current_user.current_tenant_id
|
||||
|
||||
join: TenantAccountJoin | None = session.scalar(
|
||||
join: TenantAccountJoin | None = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == current_user.id)
|
||||
.limit(1)
|
||||
|
||||
@@ -125,9 +125,9 @@ class ClearFreePlanTenantExpiredLogs:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def process_tenant(cls, flask_app: Flask, tenant_id: str, days: int, batch: int, session: Session):
|
||||
def process_tenant(cls, flask_app: Flask, tenant_id: str, days: int, batch: int):
|
||||
with flask_app.app_context():
|
||||
apps = session.scalars(select(App).where(App.tenant_id == tenant_id)).all()
|
||||
apps = db.session.scalars(select(App).where(App.tenant_id == tenant_id)).all()
|
||||
app_ids = [app.id for app in apps]
|
||||
while True:
|
||||
with sessionmaker(bind=db.engine, autoflush=False).begin() as session:
|
||||
@@ -375,8 +375,7 @@ class ClearFreePlanTenantExpiredLogs:
|
||||
or BillingService.get_info(tenant_id)["subscription"]["plan"] == CloudPlan.SANDBOX
|
||||
):
|
||||
# only process sandbox tenant
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
cls.process_tenant(flask_app, tenant_id, days, batch, session)
|
||||
cls.process_tenant(flask_app, tenant_id, days, batch)
|
||||
except Exception:
|
||||
logger.exception("Failed to process tenant %s", tenant_id)
|
||||
finally:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import InstrumentedAttribute, Session, scoped_session
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account
|
||||
from models.credential_permission import CredentialPermission
|
||||
from models.enums import PermissionEnum
|
||||
@@ -16,11 +17,9 @@ class CredentialPermissionService:
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_partial_member_list(
|
||||
cls, session: Session | scoped_session, credential_id: str, credential_type: str
|
||||
) -> Sequence[str]:
|
||||
def get_partial_member_list(cls, credential_id: str, credential_type: str) -> Sequence[str]:
|
||||
"""Return account_ids that have partial-member access to a credential."""
|
||||
return session.scalars(
|
||||
return db.session.scalars(
|
||||
select(CredentialPermission.account_id).where(
|
||||
CredentialPermission.credential_id == credential_id,
|
||||
CredentialPermission.credential_type == credential_type,
|
||||
|
||||
@@ -248,7 +248,7 @@ class DatasetService:
|
||||
def get_datasets(
|
||||
page,
|
||||
per_page,
|
||||
session: scoped_session | Session,
|
||||
session: scoped_session | Session | None = None,
|
||||
tenant_id=None,
|
||||
user=None,
|
||||
search=None,
|
||||
@@ -257,7 +257,7 @@ class DatasetService:
|
||||
accessible_dataset_ids: list[str] | None = None,
|
||||
include_own_datasets: bool = False,
|
||||
):
|
||||
"""Return visible datasets for a tenant, using the injected session for auxiliary permission lookups."""
|
||||
session = session or db.session
|
||||
query = select(Dataset).where(Dataset.tenant_id == tenant_id).order_by(Dataset.created_at.desc(), Dataset.id)
|
||||
|
||||
if dify_config.RBAC_ENABLED and accessible_dataset_ids is not None:
|
||||
@@ -268,7 +268,7 @@ class DatasetService:
|
||||
|
||||
if user:
|
||||
# get permitted dataset ids
|
||||
dataset_permission = session.scalars(
|
||||
dataset_permission = db.session.scalars(
|
||||
select(DatasetPermission).where(
|
||||
DatasetPermission.account_id == user.id, DatasetPermission.tenant_id == tenant_id
|
||||
)
|
||||
@@ -652,7 +652,7 @@ class DatasetService:
|
||||
raise ValueError("Dataset name already exists")
|
||||
|
||||
# Verify user has permission to update this dataset
|
||||
DatasetService.check_dataset_permission(dataset, user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
# Handle external dataset updates
|
||||
if dataset.provider == "external":
|
||||
@@ -1311,7 +1311,7 @@ class DatasetService:
|
||||
if dataset is None:
|
||||
return False
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
dataset_was_deleted.send(dataset)
|
||||
|
||||
@@ -1325,8 +1325,7 @@ class DatasetService:
|
||||
return db.session.execute(stmt).scalar_one()
|
||||
|
||||
@staticmethod
|
||||
def check_dataset_permission(dataset, user, session: scoped_session | Session):
|
||||
"""Validate dataset access for a user, using the injected session for partial-member lookups."""
|
||||
def check_dataset_permission(dataset, user):
|
||||
if dataset.tenant_id != user.current_tenant_id:
|
||||
logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id)
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
@@ -1337,7 +1336,7 @@ class DatasetService:
|
||||
if dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
|
||||
# For partial team permission, user needs explicit permission or be the maintainer.
|
||||
if dataset.maintainer != user.id:
|
||||
user_permission = session.scalar(
|
||||
user_permission = db.session.scalar(
|
||||
select(DatasetPermission)
|
||||
.where(DatasetPermission.dataset_id == dataset.id, DatasetPermission.account_id == user.id)
|
||||
.limit(1)
|
||||
@@ -1729,7 +1728,7 @@ class DocumentService:
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -31,10 +31,6 @@ class ComposerSoulLockPayload(BaseModel):
|
||||
unlocked_from_version_id: str | None = None
|
||||
|
||||
|
||||
class WorkflowAgentComposerQuery(BaseModel):
|
||||
snapshot_id: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class ComposerSavePayload(BaseModel):
|
||||
variant: ComposerVariant
|
||||
binding: ComposerBindingPayload | None = None
|
||||
|
||||
@@ -173,6 +173,14 @@ class InnerKnowledgeRetrieveRequest(BaseModel):
|
||||
class InnerKnowledgeRetrieveUsage(ResponseModel):
|
||||
"""Serialized LLM usage payload returned by dataset retrieval."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="forbid",
|
||||
populate_by_name=True,
|
||||
serialize_by_alias=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
|
||||
@@ -7,13 +7,10 @@ from sqlalchemy import or_, select
|
||||
from constants import HIDDEN_VALUE
|
||||
from core.entities.provider_configuration import ProviderConfiguration
|
||||
from core.helper import encrypter
|
||||
from core.helper.model_provider_cache import (
|
||||
ProviderCredentialsCache,
|
||||
ProviderCredentialsCacheType,
|
||||
)
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
|
||||
from core.model_manager import LBModelManager
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly, create_plugin_provider_manager
|
||||
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
|
||||
from core.provider_manager import ProviderManager
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import (
|
||||
@@ -316,10 +313,6 @@ class ModelLoadBalancingService:
|
||||
)
|
||||
db.session.add(inherit_config)
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
return inherit_config
|
||||
|
||||
@@ -441,10 +434,6 @@ class ModelLoadBalancingService:
|
||||
load_balancing_config.enabled = enabled
|
||||
load_balancing_config.updated_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
self._clear_credentials_cache(tenant_id, config_id)
|
||||
else:
|
||||
@@ -498,20 +487,12 @@ class ModelLoadBalancingService:
|
||||
|
||||
db.session.add(load_balancing_model_config)
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
# get deleted config ids
|
||||
deleted_config_ids = set(current_load_balancing_configs_dict.keys()) - updated_config_ids
|
||||
for config_id in deleted_config_ids:
|
||||
db.session.delete(current_load_balancing_configs_dict[config_id])
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
self._clear_credentials_cache(tenant_id, config_id)
|
||||
|
||||
|
||||
@@ -8,11 +8,6 @@ Archive V2 writes bundle-level Parquet objects. A bundle contains many workflow
|
||||
Bundle metadata lives in the object-store manifest instead of a database table, so archive/delete/restore does not move
|
||||
the large-table retention problem into another OLTP table.
|
||||
|
||||
Archive campaigns should use fixed absolute UTC windows for every tenant-prefix/shard execution. Relative windows are
|
||||
evaluated at process start and are not safe for multi-day rollout because each command would scan a different window.
|
||||
Per-shard `index.json` objects are derived from bundle manifests and provide run-level idempotency without adding a
|
||||
database claim table; bundle manifests remain the source of truth when an index must be rebuilt.
|
||||
|
||||
Archived tables:
|
||||
- workflow_runs
|
||||
- workflow_app_logs
|
||||
@@ -30,11 +25,9 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from threading import Lock
|
||||
from typing import Any, NotRequired, TypedDict, cast
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import click
|
||||
import pyarrow as pa
|
||||
@@ -66,7 +59,6 @@ from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWo
|
||||
from services.billing_service import BillingService
|
||||
from services.retention.workflow_run.constants import (
|
||||
ARCHIVE_BUNDLE_FORMAT,
|
||||
ARCHIVE_BUNDLE_INDEX_NAME,
|
||||
ARCHIVE_BUNDLE_MANIFEST_NAME,
|
||||
ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
)
|
||||
@@ -98,24 +90,10 @@ class ArchiveManifestDict(TypedDict):
|
||||
min_run_id: str
|
||||
max_run_id: str
|
||||
archived_at: str
|
||||
campaign_id: str
|
||||
archive_window_start: str | None
|
||||
archive_window_end: str
|
||||
run_shard: str
|
||||
tables: dict[str, TableStatsManifestEntry]
|
||||
run_ids: list[str]
|
||||
|
||||
|
||||
class ArchiveBundleIndexDict(TypedDict):
|
||||
schema_version: str
|
||||
archive_format: str
|
||||
object_prefix: str
|
||||
updated_at: str
|
||||
manifest_keys: list[str]
|
||||
run_ids: list[str]
|
||||
campaign_ids: NotRequired[list[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchiveBundleIdentity:
|
||||
"""Stable identity and object prefix for one V2 archive bundle."""
|
||||
@@ -149,7 +127,6 @@ class ArchiveResult:
|
||||
object_prefix: str
|
||||
success: bool
|
||||
run_count: int = 0
|
||||
skipped_run_count: int = 0
|
||||
tables: list[TableStats] = field(default_factory=list)
|
||||
object_size_bytes: int = 0
|
||||
skipped: bool = False
|
||||
@@ -215,14 +192,11 @@ class WorkflowRunArchiver:
|
||||
tenant_prefixes: list[str]
|
||||
run_shard_index: int | None
|
||||
run_shard_total: int | None
|
||||
campaign_id: str
|
||||
_archive_index_cache: dict[str, set[str]]
|
||||
_archive_index_cache_lock: Lock
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
days: int = 90,
|
||||
batch_size: int = 10000,
|
||||
batch_size: int = 100,
|
||||
start_from: datetime.datetime | None = None,
|
||||
end_before: datetime.datetime | None = None,
|
||||
workers: int = 1,
|
||||
@@ -264,10 +238,10 @@ class WorkflowRunArchiver:
|
||||
if start_from or end_before:
|
||||
if start_from is None or end_before is None:
|
||||
raise ValueError("start_from and end_before must be provided together")
|
||||
self.start_from = self._normalize_utc_datetime(start_from)
|
||||
self.end_before = self._normalize_utc_datetime(end_before)
|
||||
if self.start_from >= self.end_before:
|
||||
if start_from >= end_before:
|
||||
raise ValueError("start_from must be earlier than end_before")
|
||||
self.start_from = start_from.replace(tzinfo=datetime.UTC)
|
||||
self.end_before = end_before.replace(tzinfo=datetime.UTC)
|
||||
else:
|
||||
self.start_from = None
|
||||
self.end_before = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days)
|
||||
@@ -285,9 +259,6 @@ class WorkflowRunArchiver:
|
||||
raise ValueError("run_shard_index must be between 0 and run_shard_total - 1")
|
||||
self.run_shard_index = run_shard_index
|
||||
self.run_shard_total = run_shard_total
|
||||
self.campaign_id = self._build_campaign_id()
|
||||
self._archive_index_cache = {}
|
||||
self._archive_index_cache_lock = Lock()
|
||||
self.limit = limit
|
||||
self.dry_run = dry_run
|
||||
self.delete_after_archive = delete_after_archive
|
||||
@@ -351,23 +322,24 @@ class WorkflowRunArchiver:
|
||||
if not runs_to_process:
|
||||
continue
|
||||
|
||||
bundle_groups = self._group_runs_for_bundles(runs_to_process)
|
||||
summary.total_bundles_processed += len(bundle_groups)
|
||||
for result in self._archive_bundle_groups(session_maker, storage, bundle_groups):
|
||||
attempted_count += result.run_count + result.skipped_run_count
|
||||
summary.runs_skipped += result.skipped_run_count
|
||||
for bundle_runs in self._group_runs_for_bundles(runs_to_process):
|
||||
summary.total_bundles_processed += 1
|
||||
with session_maker() as session:
|
||||
result = self._archive_bundle(session, storage, bundle_runs)
|
||||
|
||||
if result.skipped:
|
||||
attempted_count += result.run_count
|
||||
summary.bundles_skipped += 1
|
||||
summary.runs_skipped += result.run_count
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Skipped bundle {result.bundle_id} (tenant={result.tenant_id}, "
|
||||
f"runs={result.run_count}, skipped_runs={result.skipped_run_count}, "
|
||||
f"reason={result.error or 'already handled'})",
|
||||
f"runs={result.run_count}, reason={result.error or 'already handled'})",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
elif result.success:
|
||||
attempted_count += result.run_count
|
||||
summary.bundles_archived += 1
|
||||
summary.runs_archived += result.run_count
|
||||
self._merge_result_stats(summary, result)
|
||||
@@ -375,14 +347,15 @@ class WorkflowRunArchiver:
|
||||
click.style(
|
||||
f"{'[DRY RUN] Would archive' if self.dry_run else 'Archived'} "
|
||||
f"bundle {result.bundle_id} (tenant={result.tenant_id}, runs={result.run_count}, "
|
||||
f"skipped_runs={result.skipped_run_count}, tables={len(result.tables)}, "
|
||||
f"object_size_bytes={result.object_size_bytes}, time={result.elapsed_time:.2f}s)",
|
||||
f"tables={len(result.tables)}, object_size_bytes={result.object_size_bytes}, "
|
||||
f"time={result.elapsed_time:.2f}s)",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
if self.dry_run:
|
||||
self._echo_table_estimates(result.tables)
|
||||
else:
|
||||
attempted_count += result.run_count
|
||||
summary.bundles_failed += 1
|
||||
summary.runs_failed += result.run_count
|
||||
click.echo(
|
||||
@@ -520,35 +493,6 @@ class WorkflowRunArchiver:
|
||||
|
||||
return paid
|
||||
|
||||
def _archive_bundle_groups(
|
||||
self,
|
||||
session_maker: sessionmaker[Session],
|
||||
storage: ArchiveStorage | None,
|
||||
bundle_groups: Sequence[Sequence[WorkflowRun]],
|
||||
) -> list[ArchiveResult]:
|
||||
"""Archive grouped bundles, optionally in parallel."""
|
||||
if not bundle_groups:
|
||||
return []
|
||||
if self.workers == 1 or len(bundle_groups) == 1:
|
||||
results: list[ArchiveResult] = []
|
||||
for bundle_runs in bundle_groups:
|
||||
with session_maker() as session:
|
||||
results.append(self._archive_bundle(session, storage, bundle_runs))
|
||||
return results
|
||||
|
||||
results = []
|
||||
max_workers = min(self.workers, len(bundle_groups))
|
||||
|
||||
def archive_in_worker(bundle_runs: Sequence[WorkflowRun]) -> ArchiveResult:
|
||||
with session_maker() as session:
|
||||
return self._archive_bundle(session, storage, bundle_runs)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [executor.submit(archive_in_worker, bundle_runs) for bundle_runs in bundle_groups]
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
return results
|
||||
|
||||
def _archive_bundle(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -559,7 +503,6 @@ class WorkflowRunArchiver:
|
||||
if not runs:
|
||||
raise ValueError("runs must not be empty")
|
||||
start_time = time.time()
|
||||
original_run_count = len(runs)
|
||||
identity = self._build_bundle_identity(runs)
|
||||
result = ArchiveResult(
|
||||
bundle_id=identity.bundle_id,
|
||||
@@ -574,36 +517,12 @@ class WorkflowRunArchiver:
|
||||
if storage is None:
|
||||
raise ArchiveStorageNotConfiguredError("Archive storage not configured")
|
||||
if storage.object_exists(self._get_manifest_object_key(identity)):
|
||||
self._write_bundle_index(storage, identity)
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "bundle already archived"
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
archived_run_ids = self._load_archived_run_ids_for_identity(storage, identity)
|
||||
runs = [run for run in runs if run.id not in archived_run_ids]
|
||||
result.skipped_run_count = original_run_count - len(runs)
|
||||
if not runs:
|
||||
result.run_count = 0
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "all runs already archived in shard index"
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
identity = self._build_bundle_identity(runs)
|
||||
result.bundle_id = identity.bundle_id
|
||||
result.object_prefix = identity.object_prefix
|
||||
result.run_count = len(runs)
|
||||
if storage.object_exists(self._get_manifest_object_key(identity)):
|
||||
self._write_bundle_index(storage, identity)
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "filtered bundle already archived"
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
locked_runs = self._lock_runs_for_archive(session, [run.id for run in runs])
|
||||
if len(locked_runs) != len(runs):
|
||||
result.success = True
|
||||
@@ -628,7 +547,6 @@ class WorkflowRunArchiver:
|
||||
for table_name, payload in table_payloads.items():
|
||||
storage.put_object(self._get_table_object_key(identity, table_name), payload)
|
||||
storage.put_object(self._get_manifest_object_key(identity), manifest_data)
|
||||
self._merge_bundle_manifest_into_index(storage, identity, [run.id for run in runs])
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
@@ -678,68 +596,49 @@ class WorkflowRunArchiver:
|
||||
session: Session,
|
||||
runs: Sequence[WorkflowRun],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Extract archived rows using Core mappings to avoid ORM hydration on large retention batches."""
|
||||
"""Extract all archived table rows for a bundle."""
|
||||
run_ids = [run.id for run in runs]
|
||||
table_data: dict[str, list[dict[str, Any]]] = {}
|
||||
table_data["workflow_runs"] = [self._row_to_dict(run) for run in runs]
|
||||
|
||||
table_data["workflow_app_logs"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowAppLog,
|
||||
WorkflowAppLog.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
app_logs = list(session.scalars(select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids))))
|
||||
table_data["workflow_app_logs"] = [self._row_to_dict(row) for row in app_logs]
|
||||
|
||||
node_exec_records = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowNodeExecutionModel,
|
||||
WorkflowNodeExecutionModel.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
node_exec_ids = [str(record["id"]) for record in node_exec_records]
|
||||
table_data["workflow_node_executions"] = node_exec_records
|
||||
table_data["workflow_node_execution_offload"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowNodeExecutionOffload,
|
||||
WorkflowNodeExecutionOffload.node_execution_id,
|
||||
node_exec_ids,
|
||||
node_exec_records = list(
|
||||
session.scalars(
|
||||
select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.workflow_run_id.in_(run_ids))
|
||||
)
|
||||
)
|
||||
node_exec_ids = [record.id for record in node_exec_records]
|
||||
offload_records = []
|
||||
if node_exec_ids:
|
||||
offload_records = list(
|
||||
session.scalars(
|
||||
select(WorkflowNodeExecutionOffload).where(
|
||||
WorkflowNodeExecutionOffload.node_execution_id.in_(node_exec_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
table_data["workflow_node_executions"] = [self._row_to_dict(row) for row in node_exec_records]
|
||||
table_data["workflow_node_execution_offload"] = [self._row_to_dict(row) for row in offload_records]
|
||||
|
||||
pause_records = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowPause,
|
||||
WorkflowPause.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
pause_ids = [str(record["id"]) for record in pause_records]
|
||||
table_data["workflow_pauses"] = pause_records
|
||||
table_data["workflow_pause_reasons"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowPauseReason,
|
||||
WorkflowPauseReason.pause_id,
|
||||
pause_ids,
|
||||
)
|
||||
pause_records = list(session.scalars(select(WorkflowPause).where(WorkflowPause.workflow_run_id.in_(run_ids))))
|
||||
pause_ids = [pause.id for pause in pause_records]
|
||||
pause_reason_records = []
|
||||
if pause_ids:
|
||||
pause_reason_records = list(
|
||||
session.scalars(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids)))
|
||||
)
|
||||
table_data["workflow_pauses"] = [self._row_to_dict(row) for row in pause_records]
|
||||
table_data["workflow_pause_reasons"] = [self._row_to_dict(row) for row in pause_reason_records]
|
||||
|
||||
table_data["workflow_trigger_logs"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowTriggerLog,
|
||||
WorkflowTriggerLog.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(session)
|
||||
trigger_records: list[WorkflowTriggerLog] = []
|
||||
for run_id in run_ids:
|
||||
trigger_records.extend(trigger_repo.list_by_run_id(run_id))
|
||||
table_data["workflow_trigger_logs"] = [self._row_to_dict(row) for row in trigger_records]
|
||||
return table_data
|
||||
|
||||
@staticmethod
|
||||
def _select_rows_by_column(
|
||||
session: Session,
|
||||
model: Any,
|
||||
column: Any,
|
||||
values: Sequence[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not values:
|
||||
return []
|
||||
stmt = select(*model.__table__.columns).where(column.in_(values))
|
||||
return [dict(row) for row in session.execute(stmt).mappings().all()]
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
mapper = inspect(row).mapper
|
||||
@@ -791,9 +690,6 @@ class WorkflowRunArchiver:
|
||||
for stat in table_stats
|
||||
}
|
||||
sorted_runs = sorted(runs, key=lambda run: (run.created_at, run.id))
|
||||
end_before = self.end_before
|
||||
if end_before is None:
|
||||
raise ValueError("archive window end must be set")
|
||||
return ArchiveManifestDict(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
@@ -811,10 +707,6 @@ class WorkflowRunArchiver:
|
||||
min_run_id=min(run.id for run in runs),
|
||||
max_run_id=max(run.id for run in runs),
|
||||
archived_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
campaign_id=self.campaign_id,
|
||||
archive_window_start=self._format_window_datetime(self.start_from),
|
||||
archive_window_end=end_before.isoformat(),
|
||||
run_shard=identity.shard,
|
||||
tables=tables,
|
||||
run_ids=[run.id for run in sorted_runs],
|
||||
)
|
||||
@@ -881,158 +773,6 @@ class WorkflowRunArchiver:
|
||||
return "00-of-01"
|
||||
return f"{self.run_shard_index:02d}-of-{self.run_shard_total:02d}"
|
||||
|
||||
def _load_archived_run_ids_for_identity(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> set[str]:
|
||||
index_key = self._get_index_object_key(identity)
|
||||
with self._archive_index_cache_lock:
|
||||
cached_run_ids = self._archive_index_cache.get(index_key)
|
||||
if cached_run_ids is not None:
|
||||
return set(cached_run_ids)
|
||||
|
||||
if storage.object_exists(index_key):
|
||||
index = self._load_bundle_index(storage, identity)
|
||||
else:
|
||||
index = self._write_bundle_index(storage, identity)
|
||||
run_ids = set(index["run_ids"])
|
||||
with self._archive_index_cache_lock:
|
||||
self._archive_index_cache[index_key] = set(run_ids)
|
||||
return run_ids
|
||||
|
||||
def _load_bundle_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> ArchiveBundleIndexDict:
|
||||
index_key = self._get_index_object_key(identity)
|
||||
payload = storage.get_object(index_key)
|
||||
loaded = json.loads(payload)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(f"archive index must be an object: {index_key}")
|
||||
index = cast(ArchiveBundleIndexDict, loaded)
|
||||
expected_prefix = self._get_shard_object_prefix(identity)
|
||||
if index["schema_version"] != ARCHIVE_BUNDLE_SCHEMA_VERSION:
|
||||
raise ValueError(f"unsupported archive index schema_version: {index['schema_version']}")
|
||||
if index["archive_format"] != ARCHIVE_BUNDLE_FORMAT:
|
||||
raise ValueError(f"unsupported archive index archive_format: {index['archive_format']}")
|
||||
if index["object_prefix"] != expected_prefix:
|
||||
raise ValueError("archive index object_prefix does not match shard prefix")
|
||||
return index
|
||||
|
||||
def _write_bundle_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> ArchiveBundleIndexDict:
|
||||
index = self._build_bundle_index(storage, identity)
|
||||
storage.put_object(self._get_index_object_key(identity), json.dumps(index, indent=2).encode("utf-8"))
|
||||
with self._archive_index_cache_lock:
|
||||
self._archive_index_cache[self._get_index_object_key(identity)] = set(index["run_ids"])
|
||||
return index
|
||||
|
||||
def _merge_bundle_manifest_into_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
run_ids: Sequence[str],
|
||||
) -> ArchiveBundleIndexDict:
|
||||
index_key = self._get_index_object_key(identity)
|
||||
if storage.object_exists(index_key):
|
||||
index = self._load_bundle_index(storage, identity)
|
||||
else:
|
||||
index = self._build_bundle_index(storage, identity)
|
||||
|
||||
manifest_keys = sorted(set(index["manifest_keys"]) | {self._get_manifest_object_key(identity)})
|
||||
indexed_run_ids = sorted(set(index["run_ids"]) | set(run_ids))
|
||||
campaign_id_set: set[str] = set(index.get("campaign_ids", []))
|
||||
campaign_id_set.add(self.campaign_id)
|
||||
campaign_ids = sorted(campaign_id_set)
|
||||
updated_index = ArchiveBundleIndexDict(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
object_prefix=self._get_shard_object_prefix(identity),
|
||||
updated_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
manifest_keys=manifest_keys,
|
||||
run_ids=indexed_run_ids,
|
||||
campaign_ids=campaign_ids,
|
||||
)
|
||||
storage.put_object(index_key, json.dumps(updated_index, indent=2).encode("utf-8"))
|
||||
with self._archive_index_cache_lock:
|
||||
self._archive_index_cache[index_key] = set(indexed_run_ids)
|
||||
return updated_index
|
||||
|
||||
def _build_bundle_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> ArchiveBundleIndexDict:
|
||||
shard_prefix = self._get_shard_object_prefix(identity)
|
||||
manifest_keys = sorted(
|
||||
key for key in storage.list_objects(shard_prefix) if key.endswith(f"/{ARCHIVE_BUNDLE_MANIFEST_NAME}")
|
||||
)
|
||||
run_ids: set[str] = set()
|
||||
campaign_ids: set[str] = set()
|
||||
for manifest_key in manifest_keys:
|
||||
manifest_payload = storage.get_object(manifest_key)
|
||||
manifest = json.loads(manifest_payload)
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError(f"archive manifest must be an object: {manifest_key}")
|
||||
if manifest.get("schema_version") != ARCHIVE_BUNDLE_SCHEMA_VERSION:
|
||||
raise ValueError(
|
||||
f"unsupported bundle schema_version in {manifest_key}: {manifest.get('schema_version')}"
|
||||
)
|
||||
if manifest.get("archive_format") != ARCHIVE_BUNDLE_FORMAT:
|
||||
raise ValueError(
|
||||
f"unsupported bundle archive_format in {manifest_key}: {manifest.get('archive_format')}"
|
||||
)
|
||||
manifest_run_ids = manifest.get("run_ids")
|
||||
if not isinstance(manifest_run_ids, list):
|
||||
raise ValueError(f"manifest run_ids must be a list: {manifest_key}")
|
||||
run_ids.update(str(run_id) for run_id in manifest_run_ids)
|
||||
campaign_id = manifest.get("campaign_id")
|
||||
if isinstance(campaign_id, str):
|
||||
campaign_ids.add(campaign_id)
|
||||
return ArchiveBundleIndexDict(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
object_prefix=shard_prefix,
|
||||
updated_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
manifest_keys=manifest_keys,
|
||||
run_ids=sorted(run_ids),
|
||||
campaign_ids=sorted(campaign_ids),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=datetime.UTC)
|
||||
return value.astimezone(datetime.UTC)
|
||||
|
||||
def _build_campaign_id(self) -> str:
|
||||
start = self._format_window_datetime(self.start_from) or "unbounded"
|
||||
end = self._format_window_datetime(self.end_before)
|
||||
return f"{start}_{end}"
|
||||
|
||||
@staticmethod
|
||||
def _format_window_datetime(value: datetime.datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = WorkflowRunArchiver._normalize_utc_datetime(value)
|
||||
return normalized.isoformat().replace("+00:00", "Z")
|
||||
|
||||
@staticmethod
|
||||
def _get_shard_object_prefix(identity: ArchiveBundleIdentity) -> str:
|
||||
return (
|
||||
f"workflow-runs/v2/tenant_prefix={identity.tenant_prefix}/tenant_id={identity.tenant_id}/"
|
||||
f"year={identity.year:04d}/month={identity.month:02d}/shard={identity.shard}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_index_object_key(cls, identity: ArchiveBundleIdentity) -> str:
|
||||
return f"{cls._get_shard_object_prefix(identity)}/{ARCHIVE_BUNDLE_INDEX_NAME}"
|
||||
|
||||
@staticmethod
|
||||
def _get_table_object_key(identity: ArchiveBundleIdentity, table_name: str) -> str:
|
||||
return f"{identity.object_prefix}/{table_name}.parquet"
|
||||
|
||||
@@ -4,7 +4,6 @@ ARCHIVE_BUNDLE_NAME = f"archive.v{ARCHIVE_SCHEMA_VERSION}.zip"
|
||||
ARCHIVE_BUNDLE_SCHEMA_VERSION = "2.0"
|
||||
ARCHIVE_BUNDLE_FORMAT = "parquet"
|
||||
ARCHIVE_BUNDLE_MANIFEST_NAME = "manifest.json"
|
||||
ARCHIVE_BUNDLE_INDEX_NAME = "index.json"
|
||||
ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME = "_DELETE_STARTED"
|
||||
ARCHIVE_BUNDLE_DELETED_MARKER_NAME = "_DELETED"
|
||||
ARCHIVE_BUNDLE_RESTORE_STARTED_MARKER_NAME = "_RESTORE_STARTED"
|
||||
|
||||
@@ -427,7 +427,7 @@ class BuiltinToolManageService:
|
||||
if vis_str == "partial_members":
|
||||
credential_entity.partial_member_list = list(
|
||||
CredentialPermissionService.get_partial_member_list(
|
||||
db.session, provider.id, CredPermType.BUILTIN_TOOL_PROVIDER
|
||||
provider.id, CredPermType.BUILTIN_TOOL_PROVIDER
|
||||
)
|
||||
)
|
||||
if provider.id in borrowed_ids:
|
||||
|
||||
@@ -72,6 +72,7 @@ def mint_token(flask_app: Flask):
|
||||
prefix: str,
|
||||
subject_email: str,
|
||||
subject_issuer: str | None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> OAuthAccessToken:
|
||||
with flask_app.app_context():
|
||||
row = OAuthAccessToken(
|
||||
@@ -82,7 +83,7 @@ def mint_token(flask_app: Flask):
|
||||
subject_issuer=subject_issuer,
|
||||
client_id="difyctl",
|
||||
device_label="test-device",
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
expires_at=expires_at or (datetime.now(UTC) + timedelta(hours=1)),
|
||||
)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
@@ -111,6 +112,21 @@ def account_token(workspace_account, mint_token) -> str:
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_account_token(workspace_account, mint_token) -> str:
|
||||
account, _, _ = workspace_account
|
||||
token = "dfoa_" + uuid.uuid4().hex
|
||||
mint_token(
|
||||
token,
|
||||
account_id=account.id,
|
||||
prefix="dfoa_",
|
||||
subject_email=account.email,
|
||||
subject_issuer="dify:account",
|
||||
expires_at=datetime.now(UTC) - timedelta(minutes=1),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _flush_auth_redis(flask_app: Flask) -> Generator[None, None, None]:
|
||||
def _flush():
|
||||
|
||||
@@ -6,6 +6,7 @@ acceptance/rejection on app-scoped routes.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +17,50 @@ from extensions.ext_database import db
|
||||
from models import App, Tenant
|
||||
|
||||
|
||||
def test_expired_token_returns_401_token_expired(
|
||||
test_client: FlaskClient,
|
||||
expired_account_token: str,
|
||||
) -> None:
|
||||
"""An expired bearer is distinguishable from an unknown one: 401 with the
|
||||
domain code ``token_expired`` (+ actionable hint), not a generic 401 or 500."""
|
||||
res = test_client.get(
|
||||
"/openapi/v1/account",
|
||||
headers={"Authorization": f"Bearer {expired_account_token}"},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
assert res.json["code"] == "token_expired"
|
||||
assert res.json["hint"]
|
||||
|
||||
|
||||
def test_expired_token_replay_stays_token_expired(
|
||||
test_client: FlaskClient,
|
||||
expired_account_token: str,
|
||||
) -> None:
|
||||
"""The distinct ``expired`` negative-cache marker keeps the second hit (served
|
||||
from cache, inside NEGATIVE_TTL) reporting ``token_expired`` rather than
|
||||
collapsing into a generic unknown-token 401."""
|
||||
headers = {"Authorization": f"Bearer {expired_account_token}"}
|
||||
first = test_client.get("/openapi/v1/account", headers=headers)
|
||||
second = test_client.get("/openapi/v1/account", headers=headers)
|
||||
assert first.json["code"] == "token_expired"
|
||||
assert second.status_code == 401
|
||||
assert second.json["code"] == "token_expired"
|
||||
|
||||
|
||||
def test_unknown_token_returns_401_unauthorized_not_500(
|
||||
test_client: FlaskClient,
|
||||
workspace_account,
|
||||
) -> None:
|
||||
"""An unknown bearer is a clean 401 ``unauthorized`` — not the latent 500 the
|
||||
pipeline used to leak for unmapped InvalidBearerError."""
|
||||
res = test_client.get(
|
||||
"/openapi/v1/account",
|
||||
headers={"Authorization": "Bearer dfoa_" + uuid.uuid4().hex},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
assert res.json["code"] == "unauthorized"
|
||||
|
||||
|
||||
def test_info_accepts_account_bearer_with_apps_read_scope(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
@@ -14,24 +14,6 @@ from services.retention.workflow_run.archive_paid_plan_workflow_run import (
|
||||
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_FORMAT, ARCHIVE_BUNDLE_SCHEMA_VERSION
|
||||
|
||||
|
||||
class FakeArchiveStorage:
|
||||
def __init__(self, objects: dict[str, bytes] | None = None):
|
||||
self.objects = objects or {}
|
||||
|
||||
def object_exists(self, key: str) -> bool:
|
||||
return key in self.objects
|
||||
|
||||
def get_object(self, key: str) -> bytes:
|
||||
return self.objects[key]
|
||||
|
||||
def put_object(self, key: str, data: bytes) -> str:
|
||||
self.objects[key] = data
|
||||
return "checksum"
|
||||
|
||||
def list_objects(self, prefix: str) -> list[str]:
|
||||
return sorted(key for key in self.objects if key.startswith(prefix))
|
||||
|
||||
|
||||
class TestWorkflowRunArchiverInit:
|
||||
def test_start_from_without_end_before_raises(self):
|
||||
with pytest.raises(ValueError, match="start_from and end_before must be provided together"):
|
||||
@@ -180,9 +162,7 @@ class TestBuildArchiveBundle:
|
||||
|
||||
class TestGenerateManifest:
|
||||
def test_manifest_structure(self):
|
||||
start = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC)
|
||||
end = datetime.datetime(2025, 4, 1, tzinfo=datetime.UTC)
|
||||
archiver = WorkflowRunArchiver(start_from=start, end_before=end, run_shard_index=1, run_shard_total=4)
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
from services.retention.workflow_run.archive_paid_plan_workflow_run import TableStats
|
||||
|
||||
run = MagicMock()
|
||||
@@ -217,10 +197,6 @@ class TestGenerateManifest:
|
||||
assert manifest["workflow_run_count"] == 1
|
||||
assert manifest["workflow_node_execution_count"] == 2
|
||||
assert manifest["run_ids"] == [run.id]
|
||||
assert manifest["campaign_id"] == "2025-01-01T00:00:00Z_2025-04-01T00:00:00Z"
|
||||
assert manifest["archive_window_start"] == "2025-01-01T00:00:00Z"
|
||||
assert manifest["archive_window_end"] == "2025-04-01T00:00:00Z"
|
||||
assert manifest["run_shard"] == "01-of-04"
|
||||
assert "tables" in manifest
|
||||
assert manifest["tables"]["workflow_runs"]["row_count"] == 1
|
||||
assert manifest["tables"]["workflow_runs"]["checksum"] == "abc123"
|
||||
@@ -352,21 +328,6 @@ class TestDryRunArchive:
|
||||
|
||||
|
||||
class TestArchiveRunIdempotency:
|
||||
def _index_payload(self, archiver: WorkflowRunArchiver, run_ids: list[str], run) -> tuple[str, bytes]:
|
||||
identity = archiver._build_bundle_identity([run])
|
||||
index_key = archiver._get_index_object_key(identity)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"schema_version": ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
"archive_format": ARCHIVE_BUNDLE_FORMAT,
|
||||
"object_prefix": archiver._get_shard_object_prefix(identity),
|
||||
"updated_at": "2025-03-15T00:00:00Z",
|
||||
"manifest_keys": [],
|
||||
"run_ids": run_ids,
|
||||
}
|
||||
).encode()
|
||||
return index_key, payload
|
||||
|
||||
def test_locked_bundle_is_skipped(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = MagicMock()
|
||||
@@ -399,47 +360,3 @@ class TestArchiveRunIdempotency:
|
||||
assert result.success is True
|
||||
assert result.skipped is True
|
||||
assert result.error == "bundle already archived"
|
||||
|
||||
def test_index_skips_all_already_archived_runs(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.tenant_id = "tenant-1"
|
||||
run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
|
||||
index_key, index_payload = self._index_payload(archiver, ["run-1"], run)
|
||||
storage = FakeArchiveStorage({index_key: index_payload})
|
||||
|
||||
result = archiver._archive_bundle(MagicMock(), storage, [run])
|
||||
|
||||
assert result.success is True
|
||||
assert result.skipped is True
|
||||
assert result.run_count == 0
|
||||
assert result.skipped_run_count == 1
|
||||
assert result.error == "all runs already archived in shard index"
|
||||
|
||||
def test_index_filters_duplicate_runs_before_archive(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
archived_run = MagicMock()
|
||||
archived_run.id = "run-1"
|
||||
archived_run.tenant_id = "tenant-1"
|
||||
archived_run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
|
||||
new_run = MagicMock()
|
||||
new_run.id = "run-2"
|
||||
new_run.tenant_id = "tenant-1"
|
||||
new_run.created_at = datetime.datetime(2025, 3, 15, 11, 0, 0)
|
||||
index_key, index_payload = self._index_payload(archiver, ["run-1"], archived_run)
|
||||
storage = FakeArchiveStorage({index_key: index_payload})
|
||||
|
||||
with (
|
||||
patch.object(archiver, "_lock_runs_for_archive", return_value=[new_run]) as lock_runs,
|
||||
patch.object(archiver, "_extract_bundle_data", return_value={"workflow_runs": [{"id": "run-2"}]}),
|
||||
):
|
||||
result = archiver._archive_bundle(MagicMock(), storage, [archived_run, new_run])
|
||||
|
||||
assert result.success is True
|
||||
assert result.skipped is False
|
||||
assert result.run_count == 1
|
||||
assert result.skipped_run_count == 1
|
||||
lock_runs.assert_called_once_with(ANY, ["run-2"])
|
||||
manifest_keys = [key for key in storage.objects if key.endswith("/manifest.json")]
|
||||
assert len(manifest_keys) == 1
|
||||
|
||||
+2
-11
@@ -343,13 +343,8 @@ class TestSiteEndpoints:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_site_response_structure(self):
|
||||
payload = AppSiteUpdatePayload(
|
||||
title="My Site",
|
||||
description="Test site",
|
||||
input_placeholder="Ask me anything",
|
||||
)
|
||||
payload = AppSiteUpdatePayload(title="My Site", description="Test site")
|
||||
assert payload.title == "My Site"
|
||||
assert payload.input_placeholder == "Ask me anything"
|
||||
|
||||
def test_site_default_language_validation(self):
|
||||
payload = AppSiteUpdatePayload(default_language="en-US")
|
||||
@@ -370,7 +365,6 @@ class TestSiteEndpoints:
|
||||
site.customize_domain = None
|
||||
site.copyright = None
|
||||
site.privacy_policy = None
|
||||
site.input_placeholder = None
|
||||
site.custom_disclaimer = ""
|
||||
site.customize_token_strategy = "not_allow"
|
||||
site.prompt_public = False
|
||||
@@ -383,13 +377,11 @@ class TestSiteEndpoints:
|
||||
)
|
||||
monkeypatch.setattr(site_module, "naive_utc_now", lambda: "now")
|
||||
|
||||
with app.test_request_context("/", json={"title": "My Site", "input_placeholder": "Ask me anything"}):
|
||||
with app.test_request_context("/", json={"title": "My Site"}):
|
||||
result = method(api, SimpleNamespace(id="u1"), app_model=SimpleNamespace(id="app-1"))
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["title"] == "My Site"
|
||||
assert result["input_placeholder"] == "Ask me anything"
|
||||
assert site.input_placeholder == "Ask me anything"
|
||||
|
||||
def test_app_site_access_token_reset(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
api = site_module.AppSiteAccessTokenReset()
|
||||
@@ -406,7 +398,6 @@ class TestSiteEndpoints:
|
||||
site.customize_domain = None
|
||||
site.copyright = None
|
||||
site.privacy_policy = None
|
||||
site.input_placeholder = None
|
||||
site.custom_disclaimer = ""
|
||||
site.customize_token_strategy = "not_allow"
|
||||
site.prompt_public = False
|
||||
|
||||
+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, webapp_copyright_enabled=True)
|
||||
features = FeatureModel(can_replace_logo=True)
|
||||
return features
|
||||
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
@@ -52,7 +51,6 @@ 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,
|
||||
@@ -72,7 +70,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 = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True)
|
||||
mock_features.return_value = SimpleNamespace(can_replace_logo=False)
|
||||
|
||||
with app.test_request_context("/site"):
|
||||
result = AppSiteApi().get(app_model, end_user)
|
||||
@@ -80,7 +78,6 @@ 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"
|
||||
@@ -101,7 +98,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 = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True)
|
||||
mock_features.return_value = SimpleNamespace(can_replace_logo=False)
|
||||
|
||||
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(db_session_with_containers, account)
|
||||
BillingService.is_tenant_owner_or_admin(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(db_session_with_containers, account)
|
||||
BillingService.is_tenant_owner_or_admin(account)
|
||||
|
||||
+8
-8
@@ -410,7 +410,7 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
)
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
DatasetService.check_dataset_permission(dataset, other_user, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, other_user)
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, owner)
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, creator)
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, other)
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, member)
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
# 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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, creator)
|
||||
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, outsider)
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, member)
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, member)
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, creator)
|
||||
|
||||
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, db_session_with_containers)
|
||||
DatasetService.check_dataset_permission(dataset, member)
|
||||
|
||||
def test_check_dataset_operator_permission_rejects_only_me_for_non_creator(
|
||||
self, db_session_with_containers: Session
|
||||
|
||||
@@ -109,9 +109,7 @@ 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."
|
||||
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"][1]["config"]["prefix"] == "Review the previous node output."
|
||||
assert dumped["composition"]["layers"][2]["config"]["user"] == "Summarize the report."
|
||||
|
||||
|
||||
@@ -164,15 +162,8 @@ def test_request_builder_adds_knowledge_layer_when_configured():
|
||||
run_input = _run_input()
|
||||
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
],
|
||||
"dataset_ids": ["dataset-1"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -183,7 +174,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.sets[0].dataset_ids == ["dataset-1"]
|
||||
assert knowledge_config.dataset_ids == ["dataset-1"]
|
||||
|
||||
|
||||
def test_request_builder_can_delete_on_exit_for_cleanup_paths():
|
||||
@@ -341,7 +332,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_drive_to_shell_when_configured():
|
||||
def test_workflow_request_builder_binds_shell_to_drive_when_configured():
|
||||
run_input = _run_input()
|
||||
run_input.include_shell = True
|
||||
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
|
||||
@@ -350,11 +341,11 @@ def test_workflow_request_builder_binds_drive_to_shell_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}
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
def test_agent_app_request_builder_omits_shell_layer_by_default():
|
||||
@@ -376,7 +367,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_drive_to_shell_when_configured():
|
||||
def test_agent_app_request_builder_binds_shell_to_drive_when_configured():
|
||||
run_input = _agent_app_input(include_shell=True)
|
||||
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
|
||||
|
||||
@@ -384,26 +375,19 @@ def test_agent_app_request_builder_binds_drive_to_shell_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}
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
|
||||
run_input = _agent_app_input()
|
||||
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 2},
|
||||
}
|
||||
],
|
||||
"dataset_ids": ["dataset-1", "dataset-2"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 2},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -414,7 +398,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.sets[0].dataset_ids == ["dataset-1", "dataset-2"]
|
||||
assert knowledge_config.dataset_ids == ["dataset-1", "dataset-2"]
|
||||
|
||||
|
||||
# ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ─────
|
||||
|
||||
@@ -149,55 +149,3 @@ 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,15 +28,11 @@ from controllers.console.agent.roster import (
|
||||
AgentAppApi,
|
||||
AgentAppCopyApi,
|
||||
AgentAppListApi,
|
||||
AgentBuildDraftApi,
|
||||
AgentBuildDraftApplyApi,
|
||||
AgentBuildDraftCheckoutApi,
|
||||
AgentDebugConversationRefreshApi,
|
||||
AgentInviteOptionsApi,
|
||||
AgentLogMessagesApi,
|
||||
AgentLogsApi,
|
||||
AgentLogSourcesApi,
|
||||
AgentPublishApi,
|
||||
AgentRosterVersionDetailApi,
|
||||
AgentRosterVersionRestoreApi,
|
||||
AgentRosterVersionsApi,
|
||||
@@ -99,7 +95,7 @@ def _agent_app_composer_response() -> dict:
|
||||
},
|
||||
"active_config_snapshot": _version_response(),
|
||||
"agent_soul": {},
|
||||
"save_options": ["save_to_current_version"],
|
||||
"save_options": ["save_to_current_version", "save_as_new_version"],
|
||||
}
|
||||
|
||||
|
||||
@@ -155,10 +151,6 @@ 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",
|
||||
@@ -241,8 +233,6 @@ 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,
|
||||
@@ -254,8 +244,6 @@ 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,
|
||||
@@ -380,14 +368,6 @@ 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(
|
||||
@@ -395,12 +375,15 @@ 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: agent,
|
||||
lambda _self, **kwargs: SimpleNamespace(
|
||||
id=agent_id,
|
||||
role="Resolved role",
|
||||
debug_conversation_id="debug-conversation-detail",
|
||||
active_config_snapshot_id=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
@@ -537,129 +520,6 @@ 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:
|
||||
@@ -891,12 +751,7 @@ 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,
|
||||
"draft_config_id": None,
|
||||
"restored_version_id": None,
|
||||
}
|
||||
assert restored == {"result": "success", "active_config_snapshot_id": version_id}
|
||||
assert captured_restore == {
|
||||
"tenant_id": "tenant-1",
|
||||
"agent_id": agent_id,
|
||||
@@ -1032,7 +887,7 @@ def test_agent_observability_routes_resolve_app_from_agent_id(
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(roster_controller, "resolve_agent_runtime_app_model", lambda **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller, "_agent_observability_service", lambda: FakeObservabilityService())
|
||||
|
||||
account = SimpleNamespace(id=account_id, timezone="UTC")
|
||||
@@ -1108,11 +963,10 @@ 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: captured_load.update(kwargs) or _workflow_composer_response(node_id=kwargs["node_id"]),
|
||||
lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
@@ -1139,12 +993,8 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
|
||||
},
|
||||
)
|
||||
|
||||
with app.test_request_context("?snapshot_id=preview-version"):
|
||||
workflow_state = unwrap(WorkflowAgentComposerApi.get)(
|
||||
WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1"
|
||||
)
|
||||
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"
|
||||
@@ -1242,11 +1092,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
"agent_soul": {"prompt": {"system_prompt": "x"}},
|
||||
}
|
||||
|
||||
def load_agent_composer(**kwargs: object) -> dict:
|
||||
monkeypatch.setattr(composer_controller, "resolve_agent_app_model", lambda **kwargs: SimpleNamespace(id="app-1"))
|
||||
|
||||
def load_agent_app_composer(**kwargs: object) -> dict:
|
||||
captured["load"] = kwargs
|
||||
return _agent_app_composer_response()
|
||||
|
||||
def save_agent_composer(**kwargs: object) -> dict:
|
||||
def save_agent_app_composer(**kwargs: object) -> dict:
|
||||
captured["save"] = kwargs
|
||||
return _agent_app_composer_response()
|
||||
|
||||
@@ -1260,13 +1112,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"load_agent_composer",
|
||||
load_agent_composer,
|
||||
"load_agent_app_composer",
|
||||
load_agent_app_composer,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"save_agent_composer",
|
||||
save_agent_composer,
|
||||
"save_agent_app_composer",
|
||||
save_agent_app_composer,
|
||||
)
|
||||
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
||||
monkeypatch.setattr(
|
||||
@@ -1281,13 +1133,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"])["agent_id"] == agent_id
|
||||
assert cast(dict[str, object], captured["load"])["app_id"] == "app-1"
|
||||
|
||||
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"])["agent_id"] == agent_id
|
||||
assert cast(dict[str, object], captured["save"])["app_id"] == "app-1"
|
||||
assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), "tenant-1", agent_id) == {
|
||||
"result": "success",
|
||||
"errors": [],
|
||||
@@ -1298,7 +1150,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"])["agent_id"] == agent_id
|
||||
assert cast(dict[str, object], captured["candidates"])["app_id"] == "app-1"
|
||||
|
||||
|
||||
def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
|
||||
@@ -1320,7 +1172,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_runtime_app_model", resolve_agent_app_model)
|
||||
monkeypatch.setattr(completion_controller, "resolve_agent_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)
|
||||
|
||||
@@ -1523,7 +1375,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_runtime_app_model", resolve_agent_app_model)
|
||||
monkeypatch.setattr(message_controller, "resolve_agent_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_runtime_app_model", lambda *, tenant_id, agent_id: _app_model())
|
||||
monkeypatch.setattr(module, "resolve_agent_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_runtime_app_model", lambda *, tenant_id, agent_id: _app_model())
|
||||
monkeypatch.setattr(module, "resolve_agent_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_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_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_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_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_runtime_app_model", return_value=_APP),
|
||||
patch(f"{_MOD}.resolve_agent_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_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_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_runtime_app_model", return_value=_APP),
|
||||
patch(f"{_MOD}.resolve_agent_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_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_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_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_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_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_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_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_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_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_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_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.resolve_agent_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,7 +379,6 @@ 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,
|
||||
)
|
||||
@@ -422,7 +421,6 @@ 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, caplog: pytest.LogCaptureFixture):
|
||||
def test_http_error_fallback(self):
|
||||
query = version_module.VersionQuery(current_version="1.0.0")
|
||||
|
||||
with (
|
||||
@@ -79,12 +79,15 @@ class TestCheckVersionUpdate:
|
||||
"get",
|
||||
side_effect=Exception("boom"),
|
||||
),
|
||||
caplog.at_level(logging.WARNING, logger="controllers.console.version"),
|
||||
patch.object(
|
||||
version_module.logger,
|
||||
"warning",
|
||||
) as log_warning,
|
||||
):
|
||||
result = version_module.check_version_update(query)
|
||||
|
||||
assert result.version == "1.0.0"
|
||||
assert "Check update version error" in caplog.text
|
||||
log_warning.assert_called_once()
|
||||
|
||||
def test_new_version_available(self):
|
||||
query = version_module.VersionQuery(current_version="1.0.0")
|
||||
|
||||
@@ -321,3 +321,56 @@ def test_guard_no_external_identity_when_subject_email_absent(app):
|
||||
view()
|
||||
|
||||
assert received["data"].external_identity is None
|
||||
|
||||
|
||||
# --- auth-failure mapping (no raw 500 leak) ---
|
||||
|
||||
|
||||
def test_guard_expired_token_raises_session_expired_401(app):
|
||||
from controllers.openapi._errors import OpenApiErrorCode, SessionExpired
|
||||
from libs.oauth_bearer import TokenExpiredError
|
||||
|
||||
router = _make_router()
|
||||
|
||||
with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}):
|
||||
with (
|
||||
patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"),
|
||||
patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth,
|
||||
patch("controllers.openapi.auth.pipeline.current_edition", return_value=Edition.CE),
|
||||
):
|
||||
mock_auth.return_value.authenticate.side_effect = TokenExpiredError("token_expired")
|
||||
|
||||
@router.guard(scope=Scope.FULL)
|
||||
def view(*, auth_data):
|
||||
pass
|
||||
|
||||
with pytest.raises(SessionExpired) as exc:
|
||||
view()
|
||||
|
||||
assert exc.value.code == 401
|
||||
assert exc.value.error_code == OpenApiErrorCode.TOKEN_EXPIRED
|
||||
|
||||
|
||||
def test_guard_invalid_token_raises_unified_401_not_500(app):
|
||||
from controllers.openapi._errors import InvalidBearer, OpenApiErrorCode
|
||||
from libs.oauth_bearer import InvalidBearerError
|
||||
|
||||
router = _make_router()
|
||||
|
||||
with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}):
|
||||
with (
|
||||
patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"),
|
||||
patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth,
|
||||
patch("controllers.openapi.auth.pipeline.current_edition", return_value=Edition.CE),
|
||||
):
|
||||
mock_auth.return_value.authenticate.side_effect = InvalidBearerError("invalid_bearer")
|
||||
|
||||
@router.guard(scope=Scope.FULL)
|
||||
def view(*, auth_data):
|
||||
pass
|
||||
|
||||
with pytest.raises(InvalidBearer) as exc:
|
||||
view()
|
||||
|
||||
assert exc.value.code == 401
|
||||
assert exc.value.error_code == OpenApiErrorCode.UNAUTHORIZED
|
||||
|
||||
@@ -33,6 +33,7 @@ from controllers.openapi._errors import (
|
||||
OpenApiErrorCode,
|
||||
OpenApiErrorFormatter,
|
||||
RecipientSurfaceMismatch,
|
||||
SessionExpired,
|
||||
)
|
||||
from controllers.service_api.app.error import (
|
||||
AppUnavailableError,
|
||||
@@ -353,3 +354,20 @@ class TestErrorCodeEnumRegistration:
|
||||
schema = model.__schema__
|
||||
assert schema["type"] == "string"
|
||||
assert set(schema["enum"]) == {member.value for member in OpenApiErrorCode}
|
||||
|
||||
|
||||
class TestSessionExpired:
|
||||
def test_session_expired_emits_token_expired_401_with_hint(self):
|
||||
fmt = OpenApiErrorFormatter()
|
||||
e = SessionExpired()
|
||||
data = {"code": "unauthorized", "message": e.description, "status": 401}
|
||||
|
||||
wire = fmt.finalize(e, data, 401)
|
||||
|
||||
assert wire["code"] == OpenApiErrorCode.TOKEN_EXPIRED
|
||||
assert wire["status"] == 401
|
||||
assert wire["hint"]
|
||||
|
||||
def test_session_expired_code_is_401(self):
|
||||
assert SessionExpired.code == 401
|
||||
assert SessionExpired.error_code == OpenApiErrorCode.TOKEN_EXPIRED
|
||||
|
||||
@@ -112,7 +112,6 @@ 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,
|
||||
@@ -133,7 +132,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
monkeypatch.setattr(
|
||||
site_module.FeatureService,
|
||||
"get_features",
|
||||
lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True),
|
||||
lambda tenant_id, **_kwargs: SimpleNamespace(can_replace_logo=True),
|
||||
)
|
||||
|
||||
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
|
||||
@@ -168,7 +167,6 @@ 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,
|
||||
@@ -188,72 +186,6 @@ 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."""
|
||||
|
||||
@@ -324,7 +256,6 @@ 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,
|
||||
@@ -449,7 +380,6 @@ 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,
|
||||
@@ -467,7 +397,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
|
||||
monkeypatch.setattr(
|
||||
site_module.FeatureService,
|
||||
"get_features",
|
||||
lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True),
|
||||
lambda tenant_id, **_kwargs: SimpleNamespace(can_replace_logo=True),
|
||||
)
|
||||
|
||||
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
|
||||
@@ -502,7 +432,6 @@ 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")
|
||||
== "snap-1"
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.WEB_APP, snapshot_id="snap-1")
|
||||
@@ -111,12 +111,7 @@ class TestGenerateSuccess:
|
||||
|
||||
assert result == {"result": "ok"}
|
||||
thread_obj.start.assert_called_once()
|
||||
generator._resolve_agent.assert_called_once_with(
|
||||
app_model,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=user,
|
||||
)
|
||||
generator._resolve_agent.assert_called_once_with(app_model)
|
||||
|
||||
def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture):
|
||||
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
|
||||
|
||||
@@ -46,13 +46,6 @@ 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,7 +14,6 @@ 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": {
|
||||
@@ -85,24 +84,14 @@ 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,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
) # type: ignore[arg-type]
|
||||
agent, snap, soul = AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type]
|
||||
|
||||
assert agent is bound_agent
|
||||
assert snap == snapshot.id
|
||||
assert agent is inner_agent
|
||||
assert snap is snapshot
|
||||
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,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
) # type: ignore[arg-type]
|
||||
AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type]
|
||||
|
||||
@@ -85,49 +85,6 @@ 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",
|
||||
@@ -171,15 +128,7 @@ 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",
|
||||
"shell",
|
||||
"drive",
|
||||
"history",
|
||||
"llm",
|
||||
]
|
||||
assert names == ["agent_soul_prompt", "agent_app_user_prompt", "execution_context", "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"
|
||||
@@ -220,19 +169,12 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
"knowledge": {
|
||||
"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,
|
||||
},
|
||||
}
|
||||
],
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query_config": {
|
||||
"top_k": 3,
|
||||
"score_threshold": 0.5,
|
||||
"score_threshold_enabled": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -247,12 +189,10 @@ 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)
|
||||
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
|
||||
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
|
||||
|
||||
def test_build_raises_when_model_missing(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
@@ -302,26 +242,9 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
|
||||
|
||||
def _soul_with_model_and_skill() -> AgentSoulConfig:
|
||||
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.",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"
|
||||
return soul
|
||||
|
||||
|
||||
class TestAgentAppDriveLayer:
|
||||
@@ -329,7 +252,28 @@ class TestAgentAppDriveLayer:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
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}
|
||||
],
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
@@ -339,20 +283,27 @@ class TestAgentAppDriveLayer:
|
||||
|
||||
drive = next(layer for layer in result.request.composition.layers if layer.name == "drive")
|
||||
assert drive.type == "dify.drive"
|
||||
assert drive.deps == {"shell": DIFY_SHELL_LAYER_ID}
|
||||
assert drive.deps == {"execution_context": "execution_context"}
|
||||
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"]
|
||||
# shell enters first; drive uses that shell to materialize mentioned targets.
|
||||
# injected right after execution_context, mirroring the workflow surface
|
||||
names = [layer.name for layer in result.request.composition.layers]
|
||||
assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 1
|
||||
assert names.index("drive") == names.index(DIFY_SHELL_LAYER_ID) + 1
|
||||
assert names.index("drive") == names.index("execution_context") + 1
|
||||
|
||||
def test_drive_layer_injected_with_empty_catalog_and_drive_depends_on_shell(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_drive_layer_injected_with_empty_catalog_and_shell_depends_on_it(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]
|
||||
@@ -363,14 +314,12 @@ 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"}
|
||||
assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref == "agent-agent-1"
|
||||
assert layers["drive"].deps == {"shell": DIFY_SHELL_LAYER_ID}
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": "execution_context",
|
||||
"drive": "drive",
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
def test_no_drive_layer_when_flag_disabled(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
@@ -385,7 +334,29 @@ class TestAgentAppDriveLayer:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
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},
|
||||
],
|
||||
)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = (
|
||||
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
|
||||
@@ -408,7 +379,14 @@ class TestAgentAppDriveLayer:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
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§], "
|
||||
@@ -429,7 +407,29 @@ class TestAgentAppDriveLayer:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
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},
|
||||
],
|
||||
)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = (
|
||||
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]"
|
||||
@@ -452,6 +452,14 @@ 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,15 +394,13 @@ 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:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
assert changed is False
|
||||
configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
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:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
assert changed is False
|
||||
configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
mock_session_cls.assert_not_called()
|
||||
|
||||
|
||||
@@ -413,13 +411,10 @@ 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
|
||||
|
||||
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM, session=session)
|
||||
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:
|
||||
@@ -428,13 +423,10 @@ def test_switch_preferred_provider_type_creates_record_when_missing() -> None:
|
||||
session = Mock()
|
||||
session.execute.return_value.scalars.return_value.first.return_value = None
|
||||
|
||||
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
|
||||
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:
|
||||
@@ -1030,14 +1022,13 @@ 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:
|
||||
changed = configuration._update_load_balancing_configs_with_credential(
|
||||
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()
|
||||
@@ -1049,14 +1040,13 @@ def test_update_load_balancing_configs_returns_when_no_matching_configs() -> Non
|
||||
session = Mock()
|
||||
session.execute.return_value.scalars.return_value.all.return_value = []
|
||||
|
||||
changed = configuration._update_load_balancing_configs_with_credential(
|
||||
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()
|
||||
|
||||
|
||||
@@ -1488,15 +1478,12 @@ 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
|
||||
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.CUSTOM)
|
||||
assert changed is True
|
||||
configuration.switch_preferred_provider_type(ProviderType.CUSTOM)
|
||||
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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user