Merge remote-tracking branch 'upstream/main' into feat/openapi-rbac

This commit is contained in:
yunlu.wen 2026-06-17 09:53:32 +08:00
commit a811522d5f
1173 changed files with 50907 additions and 15231 deletions

74
.github/workflows/cli-edge.yml vendored Normal file
View File

@ -0,0 +1,74 @@
name: CLI Edge Publish
on:
push:
branches: [main]
paths:
- 'cli/**'
- 'packages/contracts/generated/api/openapi/**'
workflow_dispatch:
concurrency:
group: difyctl-edge-publish
cancel-in-progress: false
jobs:
publish:
name: build + publish edge to R2
runs-on: ${{ github.repository == 'langgenius/dify' && 'depot-ubuntu-24.04' || 'ubuntu-24.04' }}
if: vars.DIFYCTL_R2_BUCKET != ''
defaults:
run:
shell: bash
working-directory: ./cli
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Enable cross-arch native prebuilds
working-directory: ./
run: cat cli/scripts/cross-arch.pnpm.yaml >> pnpm-workspace.yaml
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Setup Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
with:
bun-version-file: cli/.bun-version
- name: Compute edge version
id: ver
run: echo "version=$(node scripts/release-naming.mjs edge-version "$(git rev-parse --short HEAD)")" >> "$GITHUB_OUTPUT"
- name: Compile standalone binaries (all targets, all-or-nothing)
run: |
CLI_VERSION="${{ steps.ver.outputs.version }}" \
DIFYCTL_CHANNEL=edge \
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
pnpm build:bin
- name: Generate sha256 checksums
run: CLI_VERSION="${{ steps.ver.outputs.version }}" scripts/release-write-checksums.sh
- name: Smoke the runner-arch binary
run: ./dist/bin/difyctl-v${{ steps.ver.outputs.version }}-linux-x64 version
- name: Publish to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DIFYCTL_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFYCTL_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED
AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED
DIFYCTL_R2_S3_ENDPOINT: ${{ vars.DIFYCTL_R2_S3_ENDPOINT }}
DIFYCTL_R2_BUCKET: ${{ vars.DIFYCTL_R2_BUCKET }}
DIFYCTL_R2_PUBLIC_BASE: ${{ vars.DIFYCTL_R2_PUBLIC_BASE }}
DIFYCTL_COMMIT: ${{ github.sha }}
run: |
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
scripts/release-r2-publish.sh edge "${{ steps.ver.outputs.version }}"

28
.github/workflows/deploy-agent.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: Deploy Agent
permissions:
contents: read
on:
workflow_run:
workflows: ["Build and Push API & Web"]
branches:
- "deploy/agent"
types:
- completed
jobs:
deploy:
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/agent'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.AGENT_SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
${{ vars.SSH_SCRIPT_AGENT || secrets.SSH_SCRIPT_AGENT }}

View File

@ -113,7 +113,7 @@ jobs:
run: vp exec playwright install --with-deps chromium
- name: Run dify-ui tests
run: vp test run --coverage --silent=passed-only
run: vp test run --project unit --coverage --silent=passed-only
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
@ -123,3 +123,26 @@ jobs:
flags: dify-ui
env:
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
dify-ui-storybook-test:
name: dify-ui Storybook Tests
runs-on: depot-ubuntu-24.04-4
defaults:
run:
shell: bash
working-directory: ./packages/dify-ui
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Install Chromium for Browser Mode
run: vp exec playwright install --with-deps chromium
- name: Run dify-ui Storybook tests
run: vp run test:storybook

View File

@ -157,7 +157,7 @@ build-web:
build-api:
@echo "Building API Docker image: $(API_IMAGE):$(VERSION)..."
docker build -t $(API_IMAGE):$(VERSION) ./api
docker build -t $(API_IMAGE):$(VERSION) -f api/Dockerfile .
@echo "API Docker image built successfully: $(API_IMAGE):$(VERSION)"
# Push Docker images

View File

@ -774,3 +774,11 @@ EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
# Human input timeout check interval in minutes
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
# Nacos remote settings source HTTP timeouts (seconds).
# Bound how long requests to the Nacos endpoint wait before failing, so a slow or
# unresponsive Nacos server cannot stall API startup or token refresh.
# Read timeout for Nacos requests (default: 10.0)
DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0
# Connect timeout for Nacos requests (default: 3.0)
DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0

View File

@ -8,18 +8,30 @@
!dify-agent/src/
!dify-agent/src/**
api/.venv
api/.venv/**
api/.env
api/*.env.*
api/.idea
api/.mypy_cache
api/.ruff_cache
api/storage/generate_files/*
api/storage/privkeys/*
api/storage/tools/*
api/storage/upload_files/*
api/logs
api/*.log*
# Environment configuration and example
.env
*.env.*
# Python related files
**/__pycache__
**/*.pyc
**/.venv/
**/.mypy_cache/
**/.ruff_cache/
**/.import_linter_cache/
**/.pytest_cache/
**/.hypothesis/
# Upload files and logs
api/storage/**
api/logs/
api/*.log*
# Tests
api/tests
# Editor configuration
**/.vscode/
**/.idea/

View File

@ -19,6 +19,7 @@ from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers import ExitIntent
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
@ -38,6 +39,7 @@ from dify_agent.protocol import (
DIFY_AGENT_MODEL_LAYER_ID,
DIFY_AGENT_OUTPUT_LAYER_ID,
CreateRunRequest,
DeferredToolResultsPayload,
LayerExitSignals,
RunComposition,
RunLayerSpec,
@ -53,6 +55,7 @@ AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_DRIVE_LAYER_ID = "drive"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
DIFY_ASK_HUMAN_LAYER_ID = "ask_human"
DIFY_SHELL_LAYER_ID = "shell"
@ -139,11 +142,18 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement; a deferred call ends the run and
# the workflow pauses via the existing HITL form mechanism (ENG-635).
ask_human_config: DifyAskHumanLayerConfig | None = None
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
include_shell: bool = False
shell_config: DifyShellLayerConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None
# Human tool results fed back into a continuation run after a HITL submission
# (ENG-638). Keyed by the original deferred tool_call_id.
deferred_tool_results: DeferredToolResultsPayload | None = None
include_history: bool = True
suspend_on_exit: bool = True
metadata: dict[str, JsonValue] = Field(default_factory=dict)
@ -178,11 +188,17 @@ class AgentBackendAgentAppRunInput(BaseModel):
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement (ENG-635).
ask_human_config: DifyAskHumanLayerConfig | None = None
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
include_shell: bool = False
shell_config: DifyShellLayerConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None
# Human tool results fed back into a continuation run after a HITL submission
# (ENG-638). Keyed by the original deferred tool_call_id.
deferred_tool_results: DeferredToolResultsPayload | None = None
include_history: bool = True
suspend_on_exit: bool = True
metadata: dict[str, JsonValue] = Field(default_factory=dict)
@ -284,6 +300,19 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.ask_human_config is not None:
# Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends
# the run with a deferred_tool_call; the caller pauses (workflow HITL) and
# later resumes with deferred_tool_results. Needs the history layer above.
layers.append(
RunLayerSpec(
name=DIFY_ASK_HUMAN_LAYER_ID,
type=DIFY_ASK_HUMAN_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.ask_human_config,
)
)
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 (back proxy);
@ -318,6 +347,7 @@ class AgentBackendRunRequestBuilder:
idempotency_key=run_input.idempotency_key,
metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot,
deferred_tool_results=run_input.deferred_tool_results,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),
@ -453,6 +483,19 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.ask_human_config is not None:
# Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends
# the run with a deferred_tool_call; the caller pauses (workflow HITL) and
# later resumes with deferred_tool_results. Needs the history layer above.
layers.append(
RunLayerSpec(
name=DIFY_ASK_HUMAN_LAYER_ID,
type=DIFY_ASK_HUMAN_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.ask_human_config,
)
)
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 (back proxy);
@ -487,6 +530,7 @@ class AgentBackendRunRequestBuilder:
idempotency_key=run_input.idempotency_key,
metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot,
deferred_tool_results=run_input.deferred_tool_results,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),

View File

@ -11,6 +11,7 @@ from .data_migration import (
migration_data_wizard,
)
from .plugin import (
backfill_plugin_auto_upgrade,
extract_plugins,
extract_unique_plugins,
install_plugins,
@ -49,6 +50,7 @@ from .vector import (
__all__ = [
"add_qdrant_index",
"archive_workflow_runs",
"backfill_plugin_auto_upgrade",
"clean_expired_messages",
"clean_workflow_runs",
"cleanup_orphaned_draft_variables",

View File

@ -1,10 +1,11 @@
import json
import logging
import time
from typing import Any, cast
import click
from pydantic import TypeAdapter
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from sqlalchemy.engine import CursorResult
from configs import dify_config
@ -15,11 +16,13 @@ from core.plugin.plugin_service import PluginService
from core.tools.utils.system_encryption import encrypt_system_params
from extensions.ext_database import db
from models import Tenant
from models.account import TenantPluginAutoUpgradeStrategy
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
from models.provider_ids import DatasourceProviderID, ToolProviderID
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
from models.tools import ToolOAuthSystemClient
from services.plugin.data_migration import PluginDataMigration
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_migration import PluginMigration
logger = logging.getLogger(__name__)
@ -402,6 +405,110 @@ def migrate_data_for_plugin():
click.echo(click.style("Migrate data for plugin completed.", fg="green"))
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
stmt = (
select(TenantPluginAutoUpgradeStrategy.tenant_id)
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)
.having(func.count(func.distinct(TenantPluginAutoUpgradeStrategy.category)) < category_count)
.order_by(TenantPluginAutoUpgradeStrategy.tenant_id)
)
if limit is not None:
stmt = stmt.limit(limit)
return stmt
def _count_auto_upgrade_strategy_tenant_ids(limit: int | None) -> int:
candidate_stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).subquery()
return db.session.scalar(select(func.count()).select_from(candidate_stmt)) or 0
def _iter_auto_upgrade_strategy_tenant_ids(limit: int | None):
stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).execution_options(yield_per=1000)
yield from db.session.scalars(stmt)
@click.command(
"backfill-plugin-auto-upgrade",
help="Backfill category-scoped plugin auto-upgrade strategies and normalize plugin lists.",
)
@click.option("--tenant-id", multiple=True, help="Tenant ID to backfill. Can be passed multiple times.")
@click.option("--limit", type=int, default=None, help="Maximum number of candidate tenants to process.")
@click.option("--batch-size", type=int, default=500, show_default=True, help="Progress reporting batch size.")
@click.option("--dry-run", is_flag=True, help="Only print candidate tenant count.")
def backfill_plugin_auto_upgrade(
tenant_id: tuple[str, ...],
limit: int | None,
batch_size: int,
dry_run: bool,
):
"""
Backfill historical auto-upgrade strategies after the category column exists.
Missing category rows are created from the tenant's tool/default row. Pure default
strategies become latest for model plugins and fix-only for all other categories.
Tenants with include/exclude plugin IDs are split
by installed plugin category using plugin daemon metadata.
"""
start_at = time.perf_counter()
candidate_count = len(tenant_id) if tenant_id else _count_auto_upgrade_strategy_tenant_ids(limit)
click.echo(click.style(f"Found {candidate_count} candidate tenants.", fg="yellow"))
if dry_run:
elapsed = time.perf_counter() - start_at
click.echo(click.style(f"Dry run completed. elapsed={elapsed:.2f}s", fg="green"))
return
tenant_ids = list(tenant_id) if tenant_id else _iter_auto_upgrade_strategy_tenant_ids(limit)
backfilled_count = 0
created_count = 0
normalized_count = 0
skipped_count = 0
failed_count = 0
for index, current_tenant_id in enumerate(tenant_ids, start=1):
try:
result = PluginAutoUpgradeService.backfill_strategy_categories(
current_tenant_id,
)
except Exception as e:
failed_count += 1
click.echo(click.style(f"Failed tenant {current_tenant_id}: {str(e)}", fg="red"))
continue
if result.created_count > 0:
backfilled_count += 1
created_count += result.created_count
elif not result.normalized:
skipped_count += 1
if result.normalized:
normalized_count += 1
if batch_size > 0 and index % batch_size == 0:
click.echo(
click.style(
f"Processed {index}/{candidate_count} tenants. "
f"backfilled={backfilled_count}, created_rows={created_count}, "
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
f"elapsed={time.perf_counter() - start_at:.2f}s",
fg="yellow",
)
)
elapsed = time.perf_counter() - start_at
click.echo(
click.style(
f"Backfill plugin auto-upgrade strategy categories completed. "
f"backfilled={backfilled_count}, created_rows={created_count}, "
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
f"elapsed={elapsed:.2f}s",
fg="green",
)
)
@click.command("extract-plugins", help="Extract plugins.")
@click.option("--output_file", prompt=True, help="The file to store the extracted plugins.", default="plugins.jsonl")
@click.option("--workers", prompt=True, help="The number of workers to extract plugins.", default=10)

View File

@ -16,7 +16,7 @@ class EnterpriseFeatureConfig(BaseSettings):
CAN_REPLACE_LOGO: bool = Field(
description="Allow customization of the enterprise logo.",
default=False,
default=True,
)
ENTERPRISE_REQUEST_TIMEOUT: int = Field(

View File

@ -20,6 +20,12 @@ class NacosHttpClient:
self.token: str | None = None
self.token_ttl = 18000
self.token_expire_time: float = 0
# Bounded timeouts so a slow or unresponsive Nacos server cannot hang the API
# service indefinitely during startup or token refresh.
self.timeout = httpx.Timeout(
float(os.getenv("DIFY_ENV_NACOS_REQUEST_TIMEOUT", "10.0")),
connect=float(os.getenv("DIFY_ENV_NACOS_CONNECT_TIMEOUT", "3.0")),
)
def http_request(
self, url: str, method: str = "GET", headers: dict[str, str] | None = None, params: dict[str, str] | None = None
@ -28,12 +34,17 @@ class NacosHttpClient:
headers = {}
if params is None:
params = {}
full_url = "http://" + self.server + url
try:
self._inject_auth_info(headers, params)
response = httpx.request(method, url="http://" + self.server + url, headers=headers, params=params)
response = httpx.request(method, url=full_url, headers=headers, params=params, timeout=self.timeout)
response.raise_for_status()
return response.text
except httpx.TimeoutException as e:
logger.warning("Request to Nacos timed out (url=%s, timeout=%s): %s", full_url, self.timeout, e)
return f"Request to Nacos timed out: {e}"
except httpx.RequestError as e:
logger.warning("Request to Nacos failed (url=%s): %s", full_url, e)
return f"Request to Nacos failed: {e}"
def _inject_auth_info(self, headers: dict[str, str], params: dict[str, str], module: str = "config") -> None:
@ -78,13 +89,16 @@ class NacosHttpClient:
params = {"username": self.username, "password": self.password}
url = "http://" + self.server + "/nacos/v1/auth/login"
try:
resp = httpx.request("POST", url, headers=None, params=params)
resp = httpx.request("POST", url, headers=None, params=params, timeout=self.timeout)
resp.raise_for_status()
response_data = resp.json()
self.token = response_data.get("accessToken")
self.token_ttl = response_data.get("tokenTtl", 18000)
self.token_expire_time = current_time + self.token_ttl - 10
return self.token
except httpx.TimeoutException:
logger.exception("[get-access-token] request to Nacos timed out (url=%s, timeout=%s)", url, self.timeout)
raise
except Exception:
logger.exception("[get-access-token] exception occur")
raise

View File

@ -0,0 +1,10 @@
from uuid import UUID
from extensions.ext_database import db
from models.model import App
from services.agent.roster_service import AgentRosterService
def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App:
"""Resolve the hidden Agent App backing an Agent Console resource."""
return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))

View File

@ -1,7 +1,10 @@
from uuid import UUID
from flask_restx import Resource
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 (
account_initialization_required,
@ -35,6 +38,10 @@ 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(
@ -176,18 +183,18 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
class AgentAppComposerApi(Resource):
@console_ns.route("/agent/<uuid:agent_id>/composer")
class AgentComposerApi(Resource):
@console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
def get(self, tenant_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(
AgentAppComposerResponse,
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id),
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id),
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@ -196,24 +203,24 @@ class AgentAppComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def put(self, tenant_id: str, account_id: str, app_model: App):
def put(self, tenant_id: str, account_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
AgentAppComposerResponse,
AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
app_id=app_id,
account_id=account_id,
payload=payload,
),
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
class AgentAppComposerValidateApi(Resource):
@console_ns.route("/agent/<uuid:agent_id>/composer/validate")
class AgentComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Agent app composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
@ -221,36 +228,36 @@ class AgentAppComposerValidateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App):
def post(self, tenant_id: str, agent_id: UUID):
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
agent_id=AgentComposerService.resolve_bound_agent_id(tenant_id=tenant_id, app_id=app_model.id),
agent_id=str(agent_id),
)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
class AgentAppComposerCandidatesApi(Resource):
@console_ns.route("/agent/<uuid:agent_id>/composer/candidates")
class AgentComposerCandidatesApi(Resource):
@console_ns.response(
200, "Agent app composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, app_model: App):
def get(self, tenant_id: str, current_user_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(
tenant_id=tenant_id,
app_id=app_model.id,
app_id=app_id,
user_id=current_user_id,
),
)

View File

@ -6,10 +6,21 @@ from pydantic import BaseModel, Field
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.app.app import (
AppDetailWithSite,
AppListQuery,
AppPagination,
UpdateAppPayload,
_normalize_app_list_query_args,
)
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
edit_permission_required,
enterprise_license_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.agent_fields import (
@ -18,12 +29,17 @@ from fields.agent_fields import (
AgentInviteOptionsResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.model import IconType
from services.agent.errors import AgentNotFoundError
from services.agent.roster_service import AgentRosterService
from services.app_service import AppListParams, AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import RosterListQuery
from services.feature_service import FeatureService
class AgentInviteOptionsQuery(RosterListQuery):
@ -34,20 +50,38 @@ class AgentIdPath(BaseModel):
agent_id: str
class AgentAppCreatePayload(BaseModel):
name: str = Field(..., min_length=1, description="Agent name")
description: str | None = Field(default=None, description="Agent description (max 400 chars)", max_length=400)
role: str = Field(default="", description="Agent role", max_length=255)
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
class AgentAppUpdatePayload(UpdateAppPayload):
role: str | None = Field(default=None, description="Agent role", max_length=255)
register_schema_models(
console_ns,
AgentAppCreatePayload,
AgentAppUpdatePayload,
AgentInviteOptionsQuery,
AgentIdPath,
AppListQuery,
UpdateAppPayload,
RosterListQuery,
)
register_response_schema_models(
console_ns,
AppDetailWithSite,
AppPagination,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
@ -55,25 +89,163 @@ def _agent_roster_service() -> AgentRosterService:
return AgentRosterService(db.session)
@console_ns.route("/agents")
class AgentRosterListApi(Resource):
@console_ns.doc(params=query_params_from_model(RosterListQuery))
@console_ns.response(200, "Agent roster list", console_ns.models[AgentRosterListResponse.__name__])
def _serialize_agent_app_detail(app_model) -> dict:
app_model = AppService().get_app(app_model)
if FeatureService.get_system_features().webapp_auth.enabled:
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
roster_service = _agent_roster_service()
agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=app_model.id)
if not agent:
raise AgentNotFoundError()
payload = AppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
payload.pop("bound_agent_id", None)
payload["app_id"] = str(app_model.id)
payload["id"] = agent.id
payload["role"] = agent.role or ""
payload["active_config_is_published"] = roster_service.active_config_is_published(
tenant_id=app_model.tenant_id,
agent=agent,
)
return payload
def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
app_ids = [str(app.id) for app in app_pagination.items]
roster_service = _agent_roster_service()
agents_by_app_id = roster_service.load_app_backing_agents_by_app_id(
tenant_id=tenant_id,
app_ids=app_ids,
)
active_config_is_published_by_agent_id = roster_service.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=list(agents_by_app_id.values()),
)
payload = AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json")
for item in payload["data"]:
app_id = item["id"]
item.pop("bound_agent_id", None)
agent = agents_by_app_id.get(app_id)
if agent:
item["app_id"] = app_id
item["id"] = agent.id
item["role"] = agent.role or ""
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
return payload
def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
return _agent_roster_service().get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
@console_ns.route("/agent")
class AgentAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(AppListQuery))
@console_ns.response(200, "Agent app list", console_ns.models[AppPagination.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str):
query = RosterListQuery.model_validate(request.args.to_dict(flat=True))
return dump_response(
AgentRosterListResponse,
_agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
),
def get(self, current_tenant_id: str, current_user: Account):
args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args))
params = AppListParams(
page=args.page,
limit=args.limit,
mode="agent",
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
is_created_by_me=args.is_created_by_me,
status="normal",
)
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params)
if app_pagination is None:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json")
@console_ns.route("/agents/invite-options")
return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id)
@console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__])
@console_ns.response(201, "Agent app created successfully", console_ns.models[AppDetailWithSite.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("apps")
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
args = AgentAppCreatePayload.model_validate(console_ns.payload)
params = CreateAppParams(
name=args.name,
description=args.description,
mode="agent",
agent_role=args.role,
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,
)
app = AppService().create_app(current_tenant_id, params, current_user)
return _serialize_agent_app_detail(app), 201
@console_ns.route("/agent/<uuid:agent_id>")
class AgentAppApi(Resource):
@console_ns.response(200, "Agent app detail", console_ns.models[AppDetailWithSite.__name__])
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _serialize_agent_app_detail(app_model)
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AppDetailWithSite.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def put(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
args = AgentAppUpdatePayload.model_validate(console_ns.payload)
args_dict: AppService.ArgsDict = {
"name": args.name,
"description": args.description or "",
"icon_type": args.icon_type,
"icon": args.icon or "",
"icon_background": args.icon_background or "",
"use_icon_as_answer_icon": args.use_icon_as_answer_icon or False,
"max_active_requests": args.max_active_requests or 0,
"role": args.role,
}
updated = AppService().update_app(app_model, args_dict)
return _serialize_agent_app_detail(updated)
@console_ns.response(204, "Agent app deleted successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def delete(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
AppService().delete_app(app_model)
return "", 204
@console_ns.route("/agent/invite-options")
class AgentInviteOptionsApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))
@console_ns.response(200, "Agent invite options", console_ns.models[AgentInviteOptionsResponse.__name__])
@ -95,21 +267,7 @@ class AgentInviteOptionsApi(Resource):
)
@console_ns.route("/agents/<uuid:agent_id>")
class AgentRosterDetailApi(Resource):
@console_ns.response(200, "Agent detail", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
return dump_response(
AgentRosterResponse,
_agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)),
)
@console_ns.route("/agents/<uuid:agent_id>/versions")
@console_ns.route("/agent/<uuid:agent_id>/versions")
class AgentRosterVersionsApi(Resource):
@console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__])
@setup_required
@ -123,7 +281,7 @@ class AgentRosterVersionsApi(Resource):
)
@console_ns.route("/agents/<uuid:agent_id>/versions/<uuid:version_id>")
@console_ns.route("/agent/<uuid:agent_id>/versions/<uuid:version_id>")
class AgentRosterVersionDetailApi(Resource):
@console_ns.response(200, "Agent version detail", console_ns.models[AgentConfigSnapshotDetailResponse.__name__])
@setup_required

View File

@ -1,5 +1,6 @@
import logging
from typing import Any
from uuid import UUID
from flask import request
from flask_restx import Resource
@ -13,8 +14,14 @@ from controllers.common.schema import (
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
from controllers.console.wraps import (
account_initialization_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import uuid_value
@ -42,7 +49,7 @@ from services.file_service import FileService
logger = logging.getLogger(__name__)
_AGENT_DRIVE_APP_MODES = [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
class AgentLogQuery(BaseModel):
@ -72,6 +79,10 @@ class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentDriveDeleteFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentLogMetaResponse(ResponseModel):
status: str
executor: str
@ -138,7 +149,7 @@ class AgentDriveDeleteResponse(ResponseModel):
config_version_id: str | None = None
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload)
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
register_response_schema_models(
console_ns,
AgentDriveDeleteResponse,
@ -152,7 +163,7 @@ register_response_schema_models(
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
if node_id:
if node_id and app_model.mode != AppMode.AGENT:
return AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
@ -163,6 +174,192 @@ def _agent_not_bound() -> tuple[dict[str, str], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _upload_skill_for_app(*, current_user: Account):
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
upload = request.files["file"]
content = upload.stream.read()
try:
manifest = SkillPackageService().validate_and_extract(content=content, filename=upload.filename or "")
except SkillPackageError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
upload_file = FileService(db.engine).upload_file(
filename=upload.filename or "skill.zip",
content=content,
mimetype=upload.mimetype or "application/zip",
user=current_user,
)
skill_ref = manifest.to_skill_ref(file_id=upload_file.id)
return {"skill": skill_ref.model_dump(exclude_none=True), "manifest": manifest.model_dump()}, 201
def _standardize_skill_for_app(*, current_user: Account, app_model: App):
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
upload = request.files["file"]
content = upload.stream.read()
try:
result = SkillStandardizeService().standardize(
content=content,
filename=upload.filename or "",
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
)
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
return result, 201
def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveMutationQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(app_model, node_id)
if not agent_id:
return _agent_not_bound()
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
upload_file = db.session.scalar(
select(UploadFile).where(
UploadFile.id == payload.upload_file_id,
UploadFile.tenant_id == app_model.tenant_id,
)
)
if upload_file is None:
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
try:
key = normalize_drive_key(f"files/{upload_file.name}")
committed = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(
key=key,
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
# ADD FILE uploads exist solely to live in the drive, so the
# drive owns (and physically cleans) the value on delete.
value_owned_by_drive=True,
)
],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
row = committed[0]
file_ref = AgentFileRefConfig.model_validate(
{
"id": row["key"],
"name": upload_file.name,
"file_id": upload_file.id,
"drive_key": row["key"],
"type": row.get("mime_type"),
"size": row.get("size"),
}
)
config_version_id = AgentComposerService.add_drive_file_ref(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
file_ref=file_ref,
app_id=app_model.id,
node_id=node_id,
)
return {
"file": {
"name": upload_file.name,
"drive_key": row["key"],
"file_id": upload_file.id,
"size": row.get("size"),
"mime_type": row.get("mime_type"),
},
"config_version_id": config_version_id,
}, 201
def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveDeleteFileQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(app_model, node_id)
if not agent_id:
return _agent_not_bound()
try:
key = normalize_drive_key(query.key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
config_version_id = AgentComposerService.remove_drive_refs(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
file_key=key,
app_id=app_model.id,
node_id=node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except Exception:
# Soul-first ordering: the ref is already gone; orphan KV rows are
# harmless and an idempotent DELETE retry cleans them.
logger.exception("agent drive delete failed for key %s (soul already updated)", key)
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveMutationQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(app_model, node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
config_version_id = AgentComposerService.remove_drive_refs(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
skill_slug=slug,
app_id=app_model.id,
node_id=node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/")
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except Exception:
logger.exception("agent drive delete failed for skill %s (soul already updated)", slug)
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
def _infer_skill_tools_for_app(*, app_model: App, slug: str):
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug)
except SkillToolInferenceError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
class AgentLogApi(Resource):
@console_ns.doc("get_agent_logs")
@ -182,6 +379,23 @@ class AgentLogApi(Resource):
return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id)
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
class AgentSkillUploadByAgentApi(Resource):
@console_ns.doc("upload_agent_skill_by_agent")
@console_ns.doc(description="Upload + validate a Skill package for an Agent App")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.response(201, "Skill validated", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _upload_skill_for_app(current_user=current_user)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
class AgentSkillUploadApi(Resource):
@console_ns.doc("upload_agent_skill")
@ -192,7 +406,7 @@ class AgentSkillUploadApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Validate an uploaded Skill package and persist the archive.
@ -200,26 +414,28 @@ class AgentSkillUploadApi(Resource):
Returns a validated skill ref (to bind into the Agent soul config on save)
plus its manifest. Standardizing into the agent drive is ENG-594.
"""
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
return _upload_skill_for_app(current_user=current_user)
upload = request.files["file"]
content = upload.stream.read()
try:
manifest = SkillPackageService().validate_and_extract(content=content, filename=upload.filename or "")
except SkillPackageError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
upload_file = FileService(db.engine).upload_file(
filename=upload.filename or "skill.zip",
content=content,
mimetype=upload.mimetype or "application/zip",
user=current_user,
)
skill_ref = manifest.to_skill_ref(file_id=upload_file.id)
return {"skill": skill_ref.model_dump(exclude_none=True), "manifest": manifest.model_dump()}, 201
@console_ns.route("/agent/<uuid:agent_id>/skills/standardize")
class AgentSkillStandardizeByAgentApi(Resource):
@console_ns.doc("standardize_agent_skill_by_agent")
@console_ns.doc(description="Validate + standardize a Skill into an Agent App drive")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.response(
201,
"Skill standardized into drive",
console_ns.models[AgentSkillStandardizeResponse.__name__],
)
@console_ns.response(400, "Invalid skill package or no bound agent")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _standardize_skill_for_app(current_user=current_user, app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/standardize")
@ -236,32 +452,43 @@ class AgentSkillStandardizeApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Upload a Skill, validate it, and standardize it into the app agent's drive."""
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
return _standardize_skill_for_app(current_user=current_user, app_model=app_model)
upload = request.files["file"]
content = upload.stream.read()
try:
result = SkillStandardizeService().standardize(
content=content,
filename=upload.filename or "",
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
)
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
return result, 201
@console_ns.route("/agent/<uuid:agent_id>/files")
class AgentDriveFilesByAgentApi(Resource):
@console_ns.doc("commit_agent_drive_file_by_agent")
@console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/<name>")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
@console_ns.response(
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
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")
@console_ns.doc(description="Delete one Agent App drive file by key")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
@console_ns.route("/apps/<uuid:app_id>/agent/files")
@ -276,73 +503,11 @@ class AgentDriveFilesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def post(self, current_user: Account, app_model: App):
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
upload_file = db.session.scalar(
select(UploadFile).where(
UploadFile.id == payload.upload_file_id,
UploadFile.tenant_id == app_model.tenant_id,
)
)
if upload_file is None:
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
try:
key = normalize_drive_key(f"files/{upload_file.name}")
committed = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(
key=key,
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
# ADD FILE uploads exist solely to live in the drive, so the
# drive owns (and physically cleans) the value on delete.
value_owned_by_drive=True,
)
],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
row = committed[0]
file_ref = AgentFileRefConfig.model_validate(
{
"id": row["key"],
"name": upload_file.name,
"file_id": upload_file.id,
"drive_key": row["key"],
"type": row.get("mime_type"),
"size": row.get("size"),
}
)
config_version_id = AgentComposerService.add_drive_file_ref(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
file_ref=file_ref,
app_id=app_model.id,
node_id=query.node_id,
)
return {
"file": {
"name": upload_file.name,
"drive_key": row["key"],
"file_id": upload_file.id,
"size": row.get("size"),
"mime_type": row.get("mime_type"),
},
"config_version_id": config_version_id,
}, 201
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model)
@console_ns.doc("delete_agent_drive_file")
@console_ns.doc(description="Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)")
@ -351,36 +516,26 @@ class AgentDriveFilesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def delete(self, current_user: Account, app_model: App):
query = query_params_from_request(AgentDriveDeleteFileQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
key = normalize_drive_key(query.key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model)
config_version_id = AgentComposerService.remove_drive_refs(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
file_key=key,
app_id=app_model.id,
node_id=query.node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except Exception:
# Soul-first ordering: the ref is already gone; orphan KV rows are
# harmless and an idempotent DELETE retry cleans them.
logger.exception("agent drive delete failed for key %s (soul already updated)", key)
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>")
class AgentSkillByAgentApi(Resource):
@console_ns.doc("delete_agent_skill_by_agent")
@console_ns.doc(description="Delete a standardized skill from an Agent App drive")
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
@ -400,34 +555,29 @@ class AgentSkillApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def delete(self, current_user: Account, app_model: App, slug: str):
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug)
config_version_id = AgentComposerService.remove_drive_refs(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
skill_slug=slug,
app_id=app_model.id,
node_id=query.node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(
tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/"
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except Exception:
logger.exception("agent drive delete failed for skill %s (soul already updated)", slug)
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>/infer-tools")
class AgentSkillInferToolsByAgentApi(Resource):
@console_ns.doc("infer_agent_skill_tools_by_agent")
@console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
@console_ns.response(
200,
"Inference result (draft suggestions, nothing persisted)",
console_ns.models[SkillToolInferenceResult.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID, slug: str):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
@ -451,16 +601,7 @@ class AgentSkillInferToolsApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def post(self, app_model: App, slug: str):
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug)
except SkillToolInferenceError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)

View File

@ -5,25 +5,31 @@ reference. This exposes the read-only "Workflow access" surface from the PRD:
which workflow apps use this Agent, without leaking the workflows' internals.
"""
from uuid import UUID
from flask_restx import Resource
from pydantic import Field
from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent.roster_service import AgentRosterService
class AgentReferencingWorkflowResponse(ResponseModel):
app_id: str
app_name: str
app_icon_type: str | None = None
app_icon: str | None = None
app_icon_background: str | None = None
app_mode: str
app_updated_at: int | None = None
workflow_id: str
workflow_version: str
node_ids: list[str] = Field(default_factory=list)
@ -34,23 +40,23 @@ class AgentReferencingWorkflowsResponse(ResponseModel):
register_response_schema_models(console_ns, AgentReferencingWorkflowsResponse)
@console_ns.route("/apps/<uuid:app_id>/agent-referencing-workflows")
@console_ns.route("/agent/<uuid:agent_id>/referencing-workflows")
class AgentAppReferencingWorkflowsResource(Resource):
@console_ns.doc("list_agent_app_referencing_workflows")
@console_ns.doc(description="List workflow apps that reference this Agent App's bound Agent (read-only)")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.response(
200,
"Referencing workflows listed successfully",
console_ns.models[AgentReferencingWorkflowsResponse.__name__],
)
@console_ns.response(404, "App not found")
@console_ns.response(404, "Agent not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
workflows = AgentRosterService(db.session).list_workflows_referencing_app_agent(
tenant_id=tenant_id, app_id=app_model.id
)

View File

@ -9,17 +9,20 @@ persists them onto the app's ``app_model_config`` without touching anything the
Soul owns.
"""
from uuid import UUID
from flask_restx import Resource
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.app.wraps import get_app_model
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from events.app_event import app_model_config_was_updated
@ -32,7 +35,6 @@ from models.agent_config_entities import (
AgentSuggestedQuestionsAfterAnswerFeatureConfig,
AgentTextToSpeechFeatureConfig,
)
from models.model import App, AppMode
from services.agent_app_feature_service import AgentAppFeatureConfigService
@ -64,22 +66,23 @@ register_schema_models(console_ns, AgentAppFeaturesPayload)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/apps/<uuid:app_id>/agent-features")
@console_ns.route("/agent/<uuid:agent_id>/features")
class AgentAppFeatureConfigResource(Resource):
@console_ns.doc("update_agent_app_features")
@console_ns.doc(description="Update an Agent App's presentation features (opener, follow-up, citations, ...)")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[AgentAppFeaturesPayload.__name__])
@console_ns.response(200, "Features updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(400, "Invalid configuration")
@console_ns.response(404, "App not found")
@console_ns.response(404, "Agent not found")
@setup_required
@login_required
@edit_permission_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user
def post(self, current_user: Account, app_model: App):
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
new_app_model_config = AgentAppFeatureConfigService.update_features(

View File

@ -22,6 +22,7 @@ from controllers.common.schema import (
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.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
@ -132,18 +133,18 @@ def _handle(exc: Exception) -> tuple[dict[str, object], int]:
raise exc
@console_ns.route("/apps/<uuid:app_id>/agent-sandbox/files")
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files")
class AgentAppSandboxListResource(Resource):
@console_ns.doc("list_agent_app_sandbox_files")
@console_ns.doc(description="List a directory in an Agent App conversation sandbox")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentSandboxListQuery)})
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxListQuery)})
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
def get(self, tenant_id: str, agent_id: UUID):
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(
@ -157,18 +158,18 @@ class AgentAppSandboxListResource(Resource):
return result.model_dump()
@console_ns.route("/apps/<uuid:app_id>/agent-sandbox/files/read")
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/read")
class AgentAppSandboxReadResource(Resource):
@console_ns.doc("read_agent_app_sandbox_file")
@console_ns.doc(description="Read a text/binary preview file in an Agent App conversation sandbox")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentSandboxFileQuery)})
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxFileQuery)})
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
def get(self, tenant_id: str, agent_id: UUID):
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(
@ -182,7 +183,7 @@ class AgentAppSandboxReadResource(Resource):
return result.model_dump()
@console_ns.route("/apps/<uuid:app_id>/agent-sandbox/files/upload")
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
class AgentAppSandboxUploadResource(Resource):
@console_ns.doc("upload_agent_app_sandbox_file")
@console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping")
@ -191,9 +192,9 @@ class AgentAppSandboxUploadResource(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App):
def post(self, tenant_id: str, agent_id: UUID):
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(

View File

@ -10,6 +10,8 @@ backend — drive data lives in the API's own DB/storage, served straight from
from __future__ import annotations
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field
@ -19,8 +21,9 @@ from controllers.common.schema import (
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
@ -33,11 +36,19 @@ class AgentDriveListQuery(BaseModel):
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveListByAgentQuery(BaseModel):
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
class AgentDriveFileQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
class AgentDriveItemResponse(ResponseModel):
key: str
size: int | None = None
@ -85,7 +96,66 @@ def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
_APP_MODES = [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
@console_ns.route("/agent/<uuid:agent_id>/drive/files")
class AgentDriveListByAgentApi(Resource):
@console_ns.doc("list_agent_drive_files_by_agent")
@console_ns.doc(description="List agent drive entries for an Agent App")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveListByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
except AgentDriveError as exc:
return _handle(exc)
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
class AgentDrivePreviewByAgentApi(Resource):
@console_ns.doc("preview_agent_drive_file_by_agent")
@console_ns.doc(description="Truncated text preview of one Agent App drive value")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/agent/<uuid:agent_id>/drive/files/download")
class AgentDriveDownloadByAgentApi(Resource):
@console_ns.doc("download_agent_drive_file_by_agent")
@console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
@ -97,7 +167,7 @@ class AgentDriveListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_APP_MODES)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveListQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
@ -121,7 +191,7 @@ class AgentDrivePreviewApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_APP_MODES)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
@ -142,7 +212,7 @@ class AgentDriveDownloadApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_APP_MODES)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
@ -157,6 +227,9 @@ class AgentDriveDownloadApi(Resource):
__all__ = [
"AgentDriveDownloadApi",
"AgentDriveDownloadByAgentApi",
"AgentDriveListApi",
"AgentDriveListByAgentApi",
"AgentDrivePreviewApi",
"AgentDrivePreviewByAgentApi",
]

View File

@ -1,6 +1,7 @@
import logging
import re
import uuid
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Literal, cast
@ -41,12 +42,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.enums import WorkflowExecutionStatus
from libs.helper import build_icon_url, to_timestamp
from libs.helper import build_icon_url, dump_response, to_timestamp
from libs.login import login_required
from models import Account, App, DatasetPermissionEnum, Workflow
from models.model import IconType
from services.app_dsl_service import AppDslService
from services.app_service import AppListParams, AppService, CreateAppParams
from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import ImportMode, ImportStatus
from services.entities.knowledge_entities.knowledge_entities import (
@ -63,7 +64,7 @@ from services.entities.knowledge_entities.knowledge_entities import (
)
from services.feature_service import FeatureService
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
register_enum_models(console_ns, IconType)
@ -73,10 +74,14 @@ _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"]
class AppListQuery(BaseModel):
class AppListBaseQuery(BaseModel):
page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)")
limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)")
mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter")
sort_by: AppListSortBy = Field(
default="last_modified",
description="Sort apps by last modified, recently created, or earliest created",
)
name: str | None = Field(default=None, description="Filter by app name")
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs")
@ -119,6 +124,14 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in creator_ids.") from exc
class AppListQuery(AppListBaseQuery):
pass
class StarredAppListQuery(AppListBaseQuery):
pass
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
normalized: dict[str, str | list[str]] = {}
indexed_tag_ids: list[tuple[int, str]] = []
@ -150,9 +163,7 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str,
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] = Field(
..., description="App mode"
)
mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = Field(..., description="App mode")
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@ -387,6 +398,13 @@ class AppPartial(ResponseModel):
create_user_name: str | None = None
author_name: str | None = None
has_draft_trigger: bool | 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.
app_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
is_starred: bool = False
@computed_field(return_type=str | None) # type: ignore
@property
@ -437,6 +455,10 @@ class AppDetailWithSite(AppDetail):
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.
app_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
@computed_field(return_type=str | None) # type: ignore
@property
@ -456,12 +478,54 @@ class AppExportResponse(ResponseModel):
data: str
def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None:
if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in apps]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in apps:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [str(app.id) for app in apps if app.mode in {"workflow", "advanced-chat"}]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
Workflow.tenant_id == tenant_id,
)
)
.scalars()
.all()
)
trigger_node_types = TRIGGER_NODE_TYPES
for workflow in draft_workflows:
node_id = None
try:
for node_id, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
continue
for app in apps:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
register_response_schema_models(console_ns, AppTraceResponse, RedirectUrlResponse, SimpleResultResponse)
register_schema_models(
console_ns,
AppListQuery,
StarredAppListQuery,
CreateAppPayload,
UpdateAppPayload,
CopyAppPayload,
@ -521,6 +585,7 @@ class AppListApi(Resource):
page=args.page,
limit=args.limit,
mode=args.mode,
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
@ -534,46 +599,7 @@ class AppListApi(Resource):
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in app_pagination.items]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in app_pagination.items:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [
str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
Workflow.tenant_id == current_tenant_id,
)
)
.scalars()
.all()
)
trigger_node_types = TRIGGER_NODE_TYPES
for workflow in draft_workflows:
node_id = None
try:
for node_id, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
continue
for app in app_pagination.items:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
return pagination_model.model_dump(mode="json"), 200
@ -609,6 +635,78 @@ class AppListApi(Resource):
return app_detail.model_dump(mode="json"), 201
@console_ns.route("/apps/starred")
class StarredAppListApi(Resource):
@console_ns.doc("list_starred_apps")
@console_ns.doc(description="Get applications starred by the current account")
@console_ns.doc(params=query_params_from_model(StarredAppListQuery))
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_session(write=False)
@with_current_user_id
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
args = StarredAppListQuery.model_validate(_normalize_app_list_query_args(request.args))
params = StarredAppListParams(
page=args.page,
limit=args.limit,
mode=args.mode,
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
is_created_by_me=args.is_created_by_me,
)
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params)
if not app_pagination:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
return pagination_model.model_dump(mode="json"), 200
@console_ns.route("/apps/<uuid:app_id>/star")
class AppStarApi(Resource):
@console_ns.doc("star_app")
@console_ns.doc(description="Star an application for the current account")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user_id
@with_session
@get_app_model(mode=None)
def post(self, session: Session, current_user_id: str, app_model: App):
AppService.star_app(session, app=app_model, account_id=current_user_id)
return dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.doc("unstar_app")
@console_ns.doc(description="Remove the current account's star from an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user_id
@with_session
@get_app_model(mode=None)
def delete(self, session: Session, current_user_id: str, app_model: App):
AppService.unstar_app(session, app=app_model, account_id=current_user_id)
return dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.route("/apps/<uuid:app_id>")
class AppApi(Resource):
@console_ns.doc("get_app_detail")
@ -628,7 +726,7 @@ class AppApi(Resource):
if FeatureService.get_system_features().webapp_auth.enabled:
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
app_model.access_mode = app_setting.access_mode
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")

View File

@ -1,5 +1,6 @@
import logging
from typing import Any, Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
@ -10,6 +11,7 @@ import services
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.error import (
AppUnavailableError,
CompletionRequestError,
@ -23,6 +25,7 @@ from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
with_current_user_id,
)
@ -186,51 +189,27 @@ class ChatMessageApi(Resource):
@edit_permission_required
@with_current_user
def post(self, current_user: Account, app_model: App):
raw_payload = console_ns.payload or {}
args_model = ChatMessagePayload.model_validate(raw_payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
return _create_chat_message(current_user=current_user, app_model=app_model)
streaming = _resolve_debugger_chat_streaming(
app_mode=AppMode.value_of(app_model.mode),
response_mode=args_model.response_mode,
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
args["response_mode"] = "streaming"
args["auto_generate_name"] = False
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
try:
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
)
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
class AgentChatMessageApi(Resource):
@console_ns.doc("create_agent_chat_message")
@console_ns.doc(description="Generate an Agent App chat message for debugging")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
@console_ns.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(404, "Agent or conversation not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _create_chat_message(current_user=current_user, app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<string:task_id>/stop")
@ -245,12 +224,79 @@ class ChatMessageStopApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@with_current_user_id
def post(self, current_user_id: str, app_model: App, task_id: str):
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.DEBUGGER,
user_id=current_user_id,
app_mode=AppMode.value_of(app_model.mode),
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop")
class AgentChatMessageStopApi(Resource):
@console_ns.doc("stop_agent_chat_message")
@console_ns.doc(description="Stop a running Agent App chat message generation")
@console_ns.doc(params={"agent_id": "Agent ID", "task_id": "Task ID to stop"})
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
def _create_chat_message(*, current_user: Account, app_model: App):
raw_payload = console_ns.payload or {}
args_model = ChatMessagePayload.model_validate(raw_payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
streaming = _resolve_debugger_chat_streaming(
app_mode=AppMode.value_of(app_model.mode),
response_mode=args_model.response_mode,
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
args["response_mode"] = "streaming"
args["auto_generate_name"] = False
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
try:
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
)
return {"result": "success"}, 200
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str):
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.DEBUGGER,
user_id=current_user_id,
app_mode=AppMode.value_of(app_model.mode),
)
return {"result": "success"}, 200

View File

@ -13,6 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes
from controllers.common.fields import SimpleResultResponse, TextFileResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.error import (
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
@ -25,6 +26,7 @@ from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from core.app.entities.app_invoke_entities import InvokeFrom
@ -183,67 +185,25 @@ class ChatMessageListApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@edit_permission_required
def get(self, app_model: App):
args = ChatMessagesQuery.model_validate(request.args.to_dict())
return _list_chat_messages(app_model=app_model)
conversation = db.session.scalar(
select(Conversation)
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
.limit(1)
)
if not conversation:
raise NotFound("Conversation Not Exists.")
if args.first_id:
first_message = db.session.scalar(
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
)
if not first_message:
raise NotFound("First message not found")
history_messages = db.session.scalars(
select(Message)
.where(
Message.conversation_id == conversation.id,
Message.created_at < first_message.created_at,
Message.id != first_message.id,
)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
else:
history_messages = db.session.scalars(
select(Message)
.where(Message.conversation_id == conversation.id)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
# Initialize has_more based on whether we have a full page
if len(history_messages) == args.limit:
current_page_first_message = history_messages[-1]
# Check if there are more messages before the current page
has_more = db.session.scalar(
select(
exists().where(
Message.conversation_id == conversation.id,
Message.created_at < current_page_first_message.created_at,
Message.id != current_page_first_message.id,
)
)
)
else:
# If we don't have a full page, there are no more messages
has_more = False
history_messages = list(reversed(history_messages))
attach_message_extra_contents(history_messages)
return MessageInfiniteScrollPaginationResponse.model_validate(
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
class AgentChatMessageListApi(Resource):
@console_ns.doc("list_agent_chat_messages")
@console_ns.doc(description="Get Agent App chat messages for a conversation with pagination")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.doc(params=query_params_from_model(ChatMessagesQuery))
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__])
@console_ns.response(404, "Agent or conversation not found")
@login_required
@account_initialization_required
@setup_required
@edit_permission_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _list_chat_messages(app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/feedbacks")
@ -261,44 +221,25 @@ class MessageFeedbackApi(Resource):
@account_initialization_required
@with_current_user
def post(self, current_user: Account, app_model: App):
args = MessageFeedbackPayload.model_validate(console_ns.payload)
return _update_message_feedback(current_user=current_user, app_model=app_model)
message_id = str(args.message_id)
message = db.session.scalar(
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
feedback = message.admin_feedback
if not args.rating and feedback:
db.session.delete(feedback)
elif args.rating and feedback:
feedback.rating = FeedbackRating(args.rating)
feedback.content = args.content
elif not args.rating and not feedback:
raise ValueError("rating cannot be None when feedback not exists")
else:
rating_value = args.rating
if rating_value is None:
raise ValueError("rating is required to create feedback")
feedback = MessageFeedback(
app_id=app_model.id,
conversation_id=message.conversation_id,
message_id=message.id,
rating=FeedbackRating(rating_value),
content=args.content,
from_source=FeedbackFromSource.ADMIN,
from_account_id=current_user.id,
)
db.session.add(feedback)
db.session.commit()
return {"result": "success"}
@console_ns.route("/agent/<uuid:agent_id>/feedbacks")
class AgentMessageFeedbackApi(Resource):
@console_ns.doc("create_agent_message_feedback")
@console_ns.doc(description="Create or update Agent App message feedback")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
@console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "Agent or message not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _update_message_feedback(current_user=current_user, app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/annotations/count")
@ -340,31 +281,28 @@ class MessageSuggestedQuestionApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@with_current_user
def get(self, current_user: Account, app_model: App, message_id: UUID):
message_id_str = str(message_id)
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
)
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
raise NotFound("Conversation not found")
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
return {"data": questions}
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions")
class AgentMessageSuggestedQuestionApi(Resource):
@console_ns.doc("get_agent_message_suggested_questions")
@console_ns.doc(description="Get suggested questions for an Agent App message")
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
@console_ns.response(
200,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@console_ns.response(404, "Agent, message, or conversation not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
@console_ns.route("/apps/<uuid:app_id>/feedbacks/export")
@ -423,14 +361,167 @@ class MessageApi(Resource):
@login_required
@account_initialization_required
def get(self, app_model: App, message_id: UUID):
message_id_str = str(message_id)
return _get_message_detail(app_model=app_model, message_id=message_id)
message = db.session.scalar(
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
@console_ns.route("/agent/<uuid:agent_id>/messages/<uuid:message_id>")
class AgentMessageApi(Resource):
@console_ns.doc("get_agent_message")
@console_ns.doc(description="Get Agent App message details by ID")
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
@console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__])
@console_ns.response(404, "Agent or message not found")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _get_message_detail(app_model=app_model, message_id=message_id)
def _list_chat_messages(*, app_model: App):
args = ChatMessagesQuery.model_validate(request.args.to_dict())
conversation = db.session.scalar(
select(Conversation)
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
.limit(1)
)
if not conversation:
raise NotFound("Conversation Not Exists.")
if args.first_id:
first_message = db.session.scalar(
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
if not first_message:
raise NotFound("First message not found")
attach_message_extra_contents([message])
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")
history_messages = db.session.scalars(
select(Message)
.where(
Message.conversation_id == conversation.id,
Message.created_at < first_message.created_at,
Message.id != first_message.id,
)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
else:
history_messages = db.session.scalars(
select(Message)
.where(Message.conversation_id == conversation.id)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
# Initialize has_more based on whether we have a full page
if len(history_messages) == args.limit:
current_page_first_message = history_messages[-1]
# Check if there are more messages before the current page
has_more = db.session.scalar(
select(
exists().where(
Message.conversation_id == conversation.id,
Message.created_at < current_page_first_message.created_at,
Message.id != current_page_first_message.id,
)
)
)
else:
# If we don't have a full page, there are no more messages
has_more = False
history_messages = list(reversed(history_messages))
attach_message_extra_contents(history_messages)
return MessageInfiniteScrollPaginationResponse.model_validate(
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
from_attributes=True,
).model_dump(mode="json")
def _update_message_feedback(*, current_user: Account, app_model: App):
args = MessageFeedbackPayload.model_validate(console_ns.payload)
message_id = str(args.message_id)
message = db.session.scalar(
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
feedback = message.admin_feedback
if not args.rating and feedback:
db.session.delete(feedback)
elif args.rating and feedback:
feedback.rating = FeedbackRating(args.rating)
feedback.content = args.content
elif not args.rating and not feedback:
raise ValueError("rating cannot be None when feedback not exists")
else:
rating_value = args.rating
if rating_value is None:
raise ValueError("rating is required to create feedback")
feedback = MessageFeedback(
app_id=app_model.id,
conversation_id=message.conversation_id,
message_id=message.id,
rating=FeedbackRating(rating_value),
content=args.content,
from_source=FeedbackFromSource.ADMIN,
from_account_id=current_user.id,
)
db.session.add(feedback)
db.session.commit()
return {"result": "success"}
def _get_message_suggested_questions(*, current_user: Account, app_model: App, message_id: UUID):
message_id_str = str(message_id)
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
)
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
raise NotFound("Conversation not found")
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
return {"data": questions}
def _get_message_detail(*, app_model: App, message_id: UUID):
message_id_str = str(message_id)
message = db.session.scalar(
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
attach_message_extra_contents([message])
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")

View File

@ -2,12 +2,12 @@ import json
import logging
from collections.abc import Sequence
from datetime import datetime
from typing import Any, NotRequired, TypedDict
from typing import Any, NotRequired, TypedDict, cast
from flask import abort, request
from flask_restx import Resource, fields
from pydantic import AliasChoices, BaseModel, Field, RootModel, ValidationError, field_validator
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
@ -449,8 +449,16 @@ class DraftWorkflowApi(Resource):
if not workflow:
raise DraftWorkflowNotExist()
# return workflow, if not found, return 404
return dump_response(WorkflowResponse, workflow)
from services.agent.workflow_publish_service import WorkflowAgentPublishService
# Return workflow with response-only Agent node job projection so the
# front-end can treat draft graph node data as the editing source.
response = WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph(
session=cast(Session, db.session),
draft_workflow=workflow,
)
return response
@setup_required
@login_required

View File

@ -994,7 +994,7 @@ class RagPipelineTransformApi(Resource):
dataset_id_str = str(dataset_id)
rag_pipeline_transform_service = RagPipelineTransformService()
result = rag_pipeline_transform_service.transform_dataset(dataset_id_str)
result = rag_pipeline_transform_service.transform_dataset(dataset_id_str, db.session)
return result

View File

@ -66,6 +66,10 @@ class RecommendedAppListResponse(ResponseModel):
categories: list[str]
class LearnDifyAppListResponse(ResponseModel):
recommended_apps: list[RecommendedAppResponse]
class RecommendedAppDetailResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
@ -76,10 +80,19 @@ register_schema_models(
RecommendedAppInfoResponse,
RecommendedAppResponse,
RecommendedAppListResponse,
LearnDifyAppListResponse,
)
register_response_schema_models(console_ns, RecommendedAppDetailResponse)
def _resolve_language(language: str | None, user: Account) -> str:
if language and language in languages:
return language
if user.interface_language:
return user.interface_language
return languages[0]
@console_ns.route("/explore/apps")
class RecommendedAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@ -90,13 +103,7 @@ class RecommendedAppListApi(Resource):
def get(self, current_user: Account):
# language args
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language = args.language
if language and language in languages:
language_prefix = language
elif current_user.interface_language:
language_prefix = current_user.interface_language
else:
language_prefix = languages[0]
language_prefix = _resolve_language(args.language, current_user)
return RecommendedAppListResponse.model_validate(
RecommendedAppService.get_recommended_apps_and_categories(db.session, language_prefix),
@ -104,6 +111,23 @@ class RecommendedAppListApi(Resource):
).model_dump(mode="json")
@console_ns.route("/explore/apps/learn-dify")
class LearnDifyAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
@login_required
@account_initialization_required
@with_current_user
def get(self, current_user: Account):
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
return LearnDifyAppListResponse.model_validate(
RecommendedAppService.get_learn_dify_apps(db.session, language_prefix),
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/explore/apps/<uuid:app_id>")
class RecommendedAppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])

View File

@ -1,10 +1,11 @@
import io
from collections.abc import Mapping
from typing import Any, Literal
from datetime import datetime
from typing import Any, Literal, TypedDict
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, ConfigDict, Field, RootModel
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden
@ -26,15 +27,33 @@ from controllers.console.wraps import (
with_current_user,
with_current_user_id,
)
from core.helper.position_helper import is_filtered
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.plugin_service import PluginService
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.tool_manager import ToolManager
from fields.base import ResponseModel
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from models.provider_ids import ToolProviderID
from services.entities.model_provider_entities import ProviderEntityResponse
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_parameter_service import PluginParameterService
from services.plugin.plugin_permission_service import PluginPermissionService
from services.tools.tools_transform_service import ToolTransformService
class AutoUpgradeSettingsResponse(TypedDict):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class ParserList(BaseModel):
@ -42,6 +61,11 @@ class ParserList(BaseModel):
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
class PluginCategoryListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
class ParserLatest(BaseModel):
plugin_ids: list[str]
@ -100,8 +124,8 @@ class ParserUninstall(BaseModel):
class ParserPermissionChange(BaseModel):
install_permission: TenantPluginPermission.InstallPermission
debug_permission: TenantPluginPermission.DebugPermission
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
class ParserDynamicOptions(BaseModel):
@ -137,13 +161,64 @@ class PluginAutoUpgradeSettingsPayload(BaseModel):
include_plugins: list[str] = Field(default_factory=list)
class ParserPreferencesChange(BaseModel):
permission: PluginPermissionSettingsPayload
class PluginAutoUpgradeChangeResponse(ResponseModel):
success: bool
message: str | None = None
class PluginAutoUpgradeSettingsResponseModel(ResponseModel):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class PluginAutoUpgradeFetchResponse(ResponseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
class PluginDeclarationResponse(ResponseModel):
version: str
author: str | None
name: str
description: I18nObject
icon: str
icon_dark: str | None = None
label: I18nObject
category: PluginCategory
created_at: datetime
resource: Mapping[str, Any]
plugins: Mapping[str, list[str] | None]
tags: list[str] = Field(default_factory=list)
repo: str | None = None
verified: bool = False
tool: Mapping[str, Any] | None = None
model: ProviderEntityResponse | None = None
endpoint: Mapping[str, Any] | None = None
agent_strategy: Mapping[str, Any] | None = None
datasource: Mapping[str, Any] | None = None
trigger: Mapping[str, Any] | None = None
meta: Mapping[str, Any]
class ParserAutoUpgradeChange(BaseModel):
model_config = ConfigDict(extra="forbid")
category: TenantPluginAutoUpgradeStrategy.PluginCategory
auto_upgrade: PluginAutoUpgradeSettingsPayload
class ParserAutoUpgradeFetch(BaseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserExcludePlugin(BaseModel):
model_config = ConfigDict(extra="forbid")
plugin_id: str
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserReadme(BaseModel):
@ -157,6 +232,63 @@ class PluginDebuggingKeyResponse(ResponseModel):
port: int
class PluginCategoryInstalledPluginResponse(ResponseModel):
id: str
name: str
tenant_id: str
plugin_id: str
plugin_unique_identifier: str
endpoints_active: int
endpoints_setups: int
installation_id: str
declaration: PluginDeclarationResponse
runtime_type: str
version: str
created_at: datetime
updated_at: datetime
source: PluginInstallationSource
checksum: str
meta: Mapping[str, Any]
class PluginCategoryBuiltinToolResponse(ResponseModel):
model_config = ConfigDict(extra="allow")
author: str
name: str
label: I18nObject
description: I18nObject
parameters: list[Mapping[str, Any]] | None = None
labels: list[str]
output_schema: Mapping[str, object]
class PluginCategoryBuiltinToolProviderResponse(ResponseModel):
model_config = ConfigDict(extra="allow")
id: str
author: str
name: str
plugin_id: str | None
plugin_unique_identifier: str | None
description: I18nObject
icon: str | Mapping[str, str]
icon_dark: str | Mapping[str, str] | None
label: I18nObject
type: ToolProviderType
team_credentials: Mapping[str, object]
is_team_authorization: bool
allow_delete: bool
tools: list[PluginCategoryBuiltinToolResponse]
labels: list[str]
class PluginCategoryListResponse(ResponseModel):
plugins: list[PluginCategoryInstalledPluginResponse]
builtin_tools: list[PluginCategoryBuiltinToolProviderResponse]
has_more: bool
class PluginDaemonOperationResponse(RootModel[Any]):
root: Any
@ -200,11 +332,6 @@ class PluginOperationSuccessResponse(ResponseModel):
message: str | None = None
class PluginPreferencesResponse(ResponseModel):
permission: PluginPermissionSettingsPayload
auto_upgrade: PluginAutoUpgradeSettingsPayload
class PluginReadmeResponse(ResponseModel):
readme: str
@ -212,6 +339,7 @@ class PluginReadmeResponse(ResponseModel):
register_schema_models(
console_ns,
ParserList,
PluginCategoryListQuery,
PluginAutoUpgradeSettingsPayload,
PluginPermissionSettingsPayload,
ParserLatest,
@ -228,13 +356,21 @@ register_schema_models(
ParserPermissionChange,
ParserDynamicOptions,
ParserDynamicOptionsWithCredentials,
ParserPreferencesChange,
ParserAutoUpgradeChange,
ParserAutoUpgradeFetch,
ParserExcludePlugin,
ParserReadme,
)
register_response_schema_models(
console_ns,
PluginAutoUpgradeChangeResponse,
PluginAutoUpgradeFetchResponse,
PluginAutoUpgradeSettingsResponseModel,
BinaryFileResponse,
PluginCategoryBuiltinToolProviderResponse,
PluginCategoryBuiltinToolResponse,
PluginCategoryInstalledPluginResponse,
PluginCategoryListResponse,
PluginDaemonOperationResponse,
PluginDebuggingKeyResponse,
PluginDynamicOptionsResponse,
@ -243,7 +379,6 @@ register_response_schema_models(
PluginManifestResponse,
PluginOperationSuccessResponse,
PluginPermissionResponse,
PluginPreferencesResponse,
PluginReadmeResponse,
PluginTaskResponse,
PluginTasksResponse,
@ -254,12 +389,36 @@ register_response_schema_models(
register_enum_models(
console_ns,
TenantPluginPermission.DebugPermission,
TenantPluginAutoUpgradeStrategy.PluginCategory,
TenantPluginAutoUpgradeStrategy.UpgradeMode,
TenantPluginAutoUpgradeStrategy.StrategySetting,
TenantPluginPermission.InstallPermission,
)
def _default_auto_upgrade_settings(
tenant_id: str,
category: TenantPluginAutoUpgradeStrategy.PluginCategory,
) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category),
"upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id),
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
def _auto_upgrade_settings_to_dict(strategy: TenantPluginAutoUpgradeStrategy) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": strategy.strategy_setting,
"upgrade_time_of_day": strategy.upgrade_time_of_day,
"upgrade_mode": strategy.upgrade_mode,
"exclude_plugins": strategy.exclude_plugins,
"include_plugins": strategy.include_plugins,
}
def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
"""
Read the uploaded file and validate its actual size before delegating to the plugin service.
@ -274,6 +433,33 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
return content
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
db_builtin_providers = {
str(ToolProviderID(provider.provider)): provider
for provider in ToolManager.list_default_builtin_providers(tenant_id)
}
builtin_providers = []
for provider in ToolManager.list_hardcoded_providers():
if is_filtered(
include_set=dify_config.POSITION_TOOL_INCLUDES_SET,
exclude_set=dify_config.POSITION_TOOL_EXCLUDES_SET,
data=provider,
name_func=lambda provider_controller: provider_controller.entity.identity.name,
):
continue
user_provider = ToolTransformService.builtin_provider_to_user_provider(
provider_controller=provider,
db_provider=db_builtin_providers.get(provider.entity.identity.name),
decrypt_credentials=False,
)
ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider)
builtin_providers.append(user_provider)
return [provider.to_dict() for provider in BuiltinToolProviderSort.sort(builtin_providers)]
@console_ns.route("/workspaces/current/plugin/debugging-key")
class PluginDebuggingKeyApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginDebuggingKeyResponse.__name__])
@ -312,6 +498,41 @@ class PluginListApi(Resource):
return jsonable_encoder({"plugins": plugins_with_total.list, "total": plugins_with_total.total})
@console_ns.route("/workspaces/current/plugin/<string:category>/list")
class PluginCategoryListApi(Resource):
@console_ns.doc(params=query_params_from_model(PluginCategoryListQuery))
@console_ns.response(200, "Success", console_ns.models[PluginCategoryListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, category: str):
args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True))
try:
plugin_category = PluginCategory(category)
except ValueError:
return {"code": "invalid_param", "message": "invalid plugin category"}, 400
try:
plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
builtin_tools = []
if plugin_category == PluginCategory.Tool:
builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id)
return dump_response(
PluginCategoryListResponse,
{
"plugins": jsonable_encoder(plugins.list),
"builtin_tools": builtin_tools,
"has_more": plugins.has_more,
},
)
@console_ns.route("/workspaces/current/plugin/list/latest-versions")
class PluginListLatestVersionsApi(Resource):
@console_ns.expect(console_ns.models[ParserLatest.__name__])
@ -713,11 +934,13 @@ class PluginChangePermissionApi(Resource):
args = ParserPermissionChange.model_validate(console_ns.payload)
return {
"success": PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
}
set_permission_result = PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
return jsonable_encoder({"success": True})
@console_ns.route("/workspaces/current/plugin/permission/fetch")
@ -806,10 +1029,10 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
return jsonable_encoder({"options": options})
@console_ns.route("/workspaces/current/plugin/preferences/change")
class PluginChangePreferencesApi(Resource):
@console_ns.expect(console_ns.models[ParserPreferencesChange.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginOperationSuccessResponse.__name__])
@console_ns.route("/workspaces/current/plugin/auto-upgrade/change")
class PluginChangeAutoUpgradeApi(Resource):
@console_ns.expect(console_ns.models[ParserAutoUpgradeChange.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeChangeResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -819,38 +1042,17 @@ class PluginChangePreferencesApi(Resource):
if not user.is_admin_or_owner:
raise Forbidden()
args = ParserPreferencesChange.model_validate(console_ns.payload)
permission = args.permission
install_permission = permission.install_permission
debug_permission = permission.debug_permission
args = ParserAutoUpgradeChange.model_validate(console_ns.payload)
auto_upgrade = args.auto_upgrade
strategy_setting = auto_upgrade.strategy_setting
upgrade_time_of_day = auto_upgrade.upgrade_time_of_day
upgrade_mode = auto_upgrade.upgrade_mode
exclude_plugins = auto_upgrade.exclude_plugins
include_plugins = auto_upgrade.include_plugins
# set permission
set_permission_result = PluginPermissionService.change_permission(
tenant_id,
install_permission,
debug_permission,
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
# set auto upgrade strategy
set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy(
tenant_id,
strategy_setting,
upgrade_time_of_day,
upgrade_mode,
exclude_plugins,
include_plugins,
auto_upgrade.strategy_setting,
auto_upgrade.upgrade_time_of_day,
auto_upgrade.upgrade_mode,
auto_upgrade.exclude_plugins,
auto_upgrade.include_plugins,
category=args.category,
)
if not set_auto_upgrade_strategy_result:
return jsonable_encoder({"success": False, "message": "Failed to set auto upgrade strategy"})
@ -858,49 +1060,35 @@ class PluginChangePreferencesApi(Resource):
return jsonable_encoder({"success": True})
@console_ns.route("/workspaces/current/plugin/preferences/fetch")
class PluginFetchPreferencesApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginPreferencesResponse.__name__])
@console_ns.route("/workspaces/current/plugin/auto-upgrade/fetch")
class PluginFetchAutoUpgradeApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserAutoUpgradeFetch))
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeFetchResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
permission = PluginPermissionService.get_permission(tenant_id)
permission_dict = {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
}
args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True))
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category)
auto_upgrade_dict = (
_auto_upgrade_settings_to_dict(auto_upgrade)
if auto_upgrade
else _default_auto_upgrade_settings(tenant_id, args.category)
)
if permission:
permission_dict["install_permission"] = permission.install_permission
permission_dict["debug_permission"] = permission.debug_permission
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id)
auto_upgrade_dict = {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
"upgrade_time_of_day": 0,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
if auto_upgrade:
auto_upgrade_dict = {
"strategy_setting": auto_upgrade.strategy_setting,
"upgrade_time_of_day": auto_upgrade.upgrade_time_of_day,
"upgrade_mode": auto_upgrade.upgrade_mode,
"exclude_plugins": auto_upgrade.exclude_plugins,
"include_plugins": auto_upgrade.include_plugins,
return jsonable_encoder(
{
"category": args.category,
"auto_upgrade": auto_upgrade_dict,
}
return jsonable_encoder({"permission": permission_dict, "auto_upgrade": auto_upgrade_dict})
)
@console_ns.route("/workspaces/current/plugin/preferences/autoupgrade/exclude")
@console_ns.route("/workspaces/current/plugin/auto-upgrade/exclude")
class PluginAutoUpgradeExcludePluginApi(Resource):
@console_ns.expect(console_ns.models[ParserExcludePlugin.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginOperationSuccessResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -909,7 +1097,9 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
# exclude one single plugin
args = ParserExcludePlugin.model_validate(console_ns.payload)
return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id)})
return jsonable_encoder(
{"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id, args.category)}
)
@console_ns.route("/workspaces/current/plugin/readme")

View File

@ -31,9 +31,9 @@ from controllers.console.wraps import (
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import TimestampField, dump_response, to_timestamp
from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp
from libs.login import login_required
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
from services.account_service import TenantService
from services.billing_service import BillingService, SubscriptionPlan
from services.enterprise.enterprise_service import EnterpriseService
@ -219,6 +219,7 @@ tenants_fields = {
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"last_opened_at": OptionalTimestampField,
"current": fields.Boolean,
}
@ -234,7 +235,12 @@ class TenantListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
tenants = TenantService.get_join_tenants(current_user)
tenant_rows: list[tuple[Tenant, TenantAccountJoin]] = [
(tenant, membership)
for tenant, membership in TenantService.get_workspaces_for_account(db.session, current_user.id)
if tenant.status == TenantStatus.NORMAL
]
tenants = [tenant for tenant, _ in tenant_rows]
tenant_dicts = []
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED
@ -247,7 +253,7 @@ class TenantListApi(Resource):
if not tenant_plans:
logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path")
for tenant in tenants:
for tenant, membership in tenant_rows:
plan: str = CloudPlan.SANDBOX
if is_saas:
tenant_plan = tenant_plans.get(tenant.id)
@ -266,6 +272,7 @@ class TenantListApi(Resource):
"name": tenant.name,
"status": tenant.status,
"created_at": tenant.created_at,
"last_opened_at": membership.last_opened_at,
"plan": plan,
"current": tenant.id == current_tenant_id if current_tenant_id else False,
}

View File

@ -1,6 +1,5 @@
import json
import logging
import re
import time
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
@ -1010,11 +1009,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
if message.status == MessageStatus.PAUSED:
message.status = MessageStatus.NORMAL
# If there are assistant files, remove markdown image links from answer
answer_text = self._task_state.answer
if self._recorded_files:
# Remove markdown image links since we're storing files separately
answer_text = re.sub(r"!\[.*?\]\(.*?\)", "", answer_text).strip()
message.answer = answer_text
message.updated_at = naive_utc_now()

View File

@ -158,6 +158,110 @@ class AgentAppGenerator(MessageBasedAppGenerator):
)
return AgentAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
def resume_after_form_submission(
self,
*,
app_model: App,
user: Account | EndUser,
conversation_id: str,
invoke_from: InvokeFrom,
) -> None:
"""Resume an Agent App conversation after a submitted ask_human HITL form.
ENG-635: triggered by a background task (not an HTTP request). Runs one
blocking turn with no user query; the runner threads the human's reply
into the agent run as deferred_tool_results and the assistant answer is
persisted to the conversation. Live streaming to a reconnected client is
out of scope here the message is persisted and can be re-fetched.
"""
agent, snapshot, agent_soul = self._resolve_agent(app_model)
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=user
)
app_config = AgentAppConfigManager.get_app_config(
app_model=app_model,
agent_soul=agent_soul,
app_model_config=app_model.app_model_config,
conversation=conversation,
)
model_conf = ModelConfigConverter.convert(app_config)
trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id)
# ENG-638: the agent backend requires the resume composition's layer
# names to match the suspended snapshot, which includes the per-turn
# user-prompt layer. So re-send the original user message (the paused
# turn's query); the continuation is driven by deferred_tool_results and
# the restored snapshot, not by re-processing this prompt. A blank prompt
# would drop the user-prompt layer and fail the snapshot match.
paused_message = db.session.scalar(
select(Message)
.where(Message.conversation_id == conversation.id, Message.query != "")
.order_by(Message.created_at.desc())
.limit(1)
)
resume_query = paused_message.query if paused_message and paused_message.query else "(resumed)"
application_generate_entity = AgentAppGenerateEntity(
task_id=str(uuid.uuid4()),
app_config=app_config,
model_conf=model_conf,
conversation_id=conversation.id,
# A resume carries no new user inputs; the human's answer is the
# submitted form, threaded in by the runner as deferred_tool_results.
# The query re-sends the paused turn's message (see above).
inputs={},
query=resume_query,
files=[],
parent_message_id=UUID_NIL,
user_id=user.id,
stream=False,
invoke_from=invoke_from,
extras={"auto_generate_conversation_name": False},
call_depth=0,
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=snapshot.id,
)
conversation, message = self._init_generate_records(application_generate_entity, conversation)
queue_manager = MessageBasedAppQueueManager(
task_id=application_generate_entity.task_id,
user_id=application_generate_entity.user_id,
invoke_from=application_generate_entity.invoke_from,
conversation_id=conversation.id,
app_mode=conversation.mode,
message_id=message.id,
)
context = contextvars.copy_context()
worker_thread = threading.Thread(
target=self._generate_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"context": context,
"application_generate_entity": application_generate_entity,
"queue_manager": queue_manager,
"conversation_id": conversation.id,
"message_id": message.id,
"user_from": UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER,
# Resume continues a paused agent run; skip input guards (see _generate_worker).
"is_resume": True,
},
)
worker_thread.start()
# Blocking: drive the chat task pipeline to persist the assistant answer.
self._handle_response(
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
conversation=conversation,
message=message,
user=user,
stream=False,
)
def _generate_worker(
self,
*,
@ -168,6 +272,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
conversation_id: str,
message_id: str,
user_from: UserFrom,
is_resume: bool = False,
) -> None:
from libs.flask_utils import preserve_flask_contexts
@ -177,20 +282,30 @@ class AgentAppGenerator(MessageBasedAppGenerator):
message = self._get_message(message_id)
app_config = application_generate_entity.app_config
# Apply app-level input guards (content moderation + annotation
# reply) before reaching the Agent backend, mirroring the EasyUI
# chat / agent-chat runners. These can short-circuit the turn.
app_model = db.session.get(App, app_config.app_id)
if app_model is None:
raise AgentAppGeneratorError("App not found")
handled, query = self._run_input_guards(
application_generate_entity=application_generate_entity,
app_model=app_model,
message=message,
queue_manager=queue_manager,
)
if handled:
return
if is_resume:
# ENG-638: a resume continues a paused agent run; the human's
# reply is threaded in by the runner as deferred_tool_results.
# The query is the replayed paused-turn message, kept only to
# match the suspended snapshot's layers — it is NOT new
# end-user input, so input guards must NOT run. Moderation or an
# annotation match on the replayed query would short-circuit the
# turn and drop the human reply, stranding the ask_human session.
query = application_generate_entity.query or ""
else:
# Apply app-level input guards (content moderation + annotation
# reply) before reaching the Agent backend, mirroring the EasyUI
# chat / agent-chat runners. These can short-circuit the turn.
app_model = db.session.get(App, app_config.app_id)
if app_model is None:
raise AgentAppGeneratorError("App not found")
handled, query = self._run_input_guards(
application_generate_entity=application_generate_entity,
app_model=app_model,
message=message,
queue_manager=queue_manager,
)
if handled:
return
dify_context = DifyRunContext(
tenant_id=app_config.tenant_id,
@ -258,7 +373,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
app_config = application_generate_entity.app_config
model_name = application_generate_entity.model_conf.model
query = application_generate_entity.query
query = application_generate_entity.query or ""
# content moderation (sensitive_word_avoidance); a blocked input yields a
# preset answer, an "overridden" action returns a sanitized query.
@ -273,7 +388,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
trace_manager=application_generate_entity.trace_manager,
)
except ModerationError as e:
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e))
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e), user_query=query)
return True, query
# annotation reply: a matching annotation answers the turn deterministically.
@ -290,7 +405,12 @@ class AgentAppGenerator(MessageBasedAppGenerator):
QueueAnnotationReplyEvent(message_annotation_id=annotation_reply.id),
PublishFrom.APPLICATION_MANAGER,
)
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=annotation_reply.content)
publish_text_answer(
queue_manager=queue_manager,
model_name=model_name,
answer=annotation_reply.content,
user_query=query,
)
return True, query
return False, query

View File

@ -14,9 +14,12 @@ import json
import logging
from typing import Any
from dify_agent.layers.ask_human import AskHumanToolArgs
from dify_agent.protocol import DeferredToolResultsPayload
from pydantic import JsonValue
from clients.agent_backend import (
AgentBackendDeferredToolCallInternalEvent,
AgentBackendError,
AgentBackendInternalEventType,
AgentBackendRunClient,
@ -27,21 +30,41 @@ from clients.agent_backend import (
)
from core.app.apps.agent_app.runtime_request_builder import (
AgentAppRuntimeBuildContext,
AgentAppRuntimeRequest,
AgentAppRuntimeRequestBuilder,
)
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope
from core.app.apps.agent_app.session_store import (
AgentAppRuntimeSessionStore,
AgentAppSessionScope,
StoredAgentAppSession,
)
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import DifyRunContext
from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form
from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage, UserPromptMessage
from models.agent_config_entities import AgentSoulConfig
logger = logging.getLogger(__name__)
def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]:
if not user_query:
return []
return [UserPromptMessage(content=user_query)]
def publish_text_answer(
*,
queue_manager: AppQueueManager,
model_name: str,
answer: str,
user_query: str | None = None,
) -> None:
"""Publish a complete assistant answer as one chunk + message-end.
The EasyUI chat task pipeline consumes a QueueLLMChunkEvent stream followed
@ -49,9 +72,10 @@ def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answ
both the backend-produced answer and short-circuited answers (moderation /
annotation reply) share the exact same persistence + SSE path.
"""
prompt_messages = _prompt_messages_from_query(user_query)
chunk = LLMResultChunk(
model=model_name,
prompt_messages=[],
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=answer)),
)
queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER)
@ -59,7 +83,7 @@ def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answ
QueueMessageEndEvent(
llm_result=LLMResult(
model=model_name,
prompt_messages=[],
prompt_messages=prompt_messages,
message=AssistantPromptMessage(content=answer),
usage=LLMUsage.empty_usage(),
),
@ -104,7 +128,13 @@ class AgentAppRunner:
agent_id=agent_id,
agent_config_snapshot_id=agent_config_snapshot_id,
)
session_snapshot = self._session_store.load_active_snapshot(scope)
# ENG-638: if a prior turn paused on ask_human and the form is now answered,
# resume by threading the human's reply into this run as deferred_tool_results.
stored = self._session_store.load_active_session(scope)
session_snapshot = stored.session_snapshot if stored is not None else None
deferred_tool_results = self._resolve_pending_ask_human(
stored=stored, dify_context=dify_context, message_id=message_id
)
runtime = self._request_builder.build(
AgentAppRuntimeBuildContext(
@ -116,18 +146,36 @@ class AgentAppRunner:
user_query=query,
idempotency_key=message_id,
session_snapshot=session_snapshot,
deferred_tool_results=deferred_tool_results,
)
)
create_response = self._agent_backend_client.create_run(runtime.request)
terminal = self._consume_stream(create_response.run_id, queue_manager=queue_manager)
if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent):
# ENG-635: the agent asked a human. End this turn with the question and
# a conversation-owned HITL form; a form submission resumes the run.
self._pause_for_ask_human(
terminal=terminal,
scope=scope,
dify_context=dify_context,
agent_soul=agent_soul,
conversation_id=conversation_id,
message_id=message_id,
model_name=model_name,
runtime=runtime,
queue_manager=queue_manager,
query=query,
)
return
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
error = getattr(terminal, "error", None) or "Agent backend run did not complete successfully."
raise AgentBackendError(str(error))
answer = self._extract_answer(terminal.output)
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, query=query)
self._save_session(
scope=scope,
backend_run_id=terminal.run_id,
@ -135,6 +183,95 @@ class AgentAppRunner:
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
)
def _pause_for_ask_human(
self,
*,
terminal: AgentBackendDeferredToolCallInternalEvent,
scope: AgentAppSessionScope,
dify_context: DifyRunContext,
agent_soul: AgentSoulConfig,
conversation_id: str,
message_id: str,
model_name: str,
runtime: AgentAppRuntimeRequest,
queue_manager: AppQueueManager,
query: str,
) -> None:
"""End the chat turn on a dify.ask_human call: create a conversation-owned
HITL form, persist the pause correlation, and surface the question."""
try:
created = create_ask_human_form(
deferred_tool_call=terminal.deferred_tool_call,
# Chat forms have no workflow node; key by the turn's message id.
node_id=message_id,
default_node_title="Agent",
contacts=agent_soul.human.contacts,
repository=self._build_form_repository(dify_context),
conversation_id=conversation_id,
)
except AskHumanFormBuildError as error:
raise AgentBackendError(f"Failed to build ask_human form for Agent App chat: {error}") from error
# Persist the snapshot + correlation so a form submission can start the
# second run with the human's answer (ENG-637/638 columns, conversation owner).
self._save_session(
scope=scope,
backend_run_id=terminal.run_id,
snapshot=terminal.session_snapshot,
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
pending_form_id=created.form_id,
pending_tool_call_id=terminal.deferred_tool_call.tool_call_id,
)
# The structured form is delivered via the HITL surface(s); the chat turn
# ends by echoing the agent's question so the conversation reflects the ask.
self._publish_answer(
queue_manager=queue_manager,
model_name=model_name,
answer=self._ask_human_message(created.args),
query=query,
)
def _resolve_pending_ask_human(
self,
*,
stored: StoredAgentAppSession | None,
dify_context: DifyRunContext,
message_id: str,
) -> DeferredToolResultsPayload | None:
"""Build deferred_tool_results when a pending ask_human form is answered."""
if stored is None or stored.pending_form_id is None or stored.pending_tool_call_id is None:
return None
outcome = resolve_ask_human_form(
form_id=stored.pending_form_id,
tenant_id=dify_context.tenant_id,
node_id=message_id,
)
if outcome is None or outcome.deferred_result is None:
# Form missing or still waiting — run a normal turn, no resume.
return None
return build_deferred_tool_results(
tool_call_id=stored.pending_tool_call_id,
result=outcome.deferred_result,
)
def _build_form_repository(self, dify_context: DifyRunContext) -> HumanInputFormRepository:
invoke_source = dify_context.invoke_from.value
return HumanInputFormRepositoryImpl(
tenant_id=dify_context.tenant_id,
app_id=dify_context.app_id,
workflow_execution_id=None,
invoke_source=invoke_source,
submission_actor_id=dify_context.user_id if invoke_source in {"debugger", "explore"} else None,
)
@staticmethod
def _ask_human_message(args: AskHumanToolArgs) -> str:
parts = [args.question]
if args.markdown:
parts.append(args.markdown)
return "\n\n".join(parts)
def _consume_stream(self, run_id: str, *, queue_manager: AppQueueManager):
terminal = None
for public_event in self._agent_backend_client.stream_events(run_id):
@ -166,10 +303,12 @@ class AgentAppRunner:
except Exception:
logger.warning("Failed to cancel stopped Agent App backend run: run_id=%s", run_id, exc_info=True)
def _publish_answer(self, *, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
def _publish_answer(
self, *, queue_manager: AppQueueManager, model_name: str, answer: str, query: str | None
) -> None:
# MVP: emit the full answer as a single chunk + message-end. The chat
# task pipeline streams the chunk over SSE and persists the message.
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, user_query=query)
def _save_session(
self,
@ -178,6 +317,8 @@ class AgentAppRunner:
backend_run_id: str,
snapshot: Any,
runtime_layer_specs: Any,
pending_form_id: str | None = None,
pending_tool_call_id: str | None = None,
) -> None:
try:
self._session_store.save_active_snapshot(
@ -185,6 +326,8 @@ class AgentAppRunner:
backend_run_id=backend_run_id,
snapshot=snapshot,
runtime_layer_specs=runtime_layer_specs,
pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id,
)
except Exception:
logger.warning(

View File

@ -18,7 +18,7 @@ from dify_agent.layers.execution_context import (
DifyExecutionContextLayerConfig,
DifyExecutionContextUserFrom,
)
from dify_agent.protocol import CreateRunRequest
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
from clients.agent_backend import (
AgentBackendAgentAppRunInput,
@ -34,6 +34,7 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
)
from core.workflow.nodes.agent_v2.runtime_request_builder import (
append_runtime_warnings,
build_ask_human_layer_config,
build_drive_layer_config,
build_shell_layer_config,
)
@ -64,6 +65,8 @@ class AgentAppRuntimeBuildContext:
user_query: str
idempotency_key: str
session_snapshot: CompositorSessionSnapshot | None = None
# ENG-638: set when resuming a chat turn after a submitted ask_human form.
deferred_tool_results: DeferredToolResultsPayload | None = None
@dataclass(frozen=True, slots=True)
@ -154,9 +157,11 @@ class AgentAppRuntimeRequestBuilder:
user_prompt=context.user_query,
tools=tools_layer,
drive_config=drive_config,
ask_human_config=build_ask_human_layer_config(agent_soul),
include_shell=dify_config.AGENT_SHELL_ENABLED,
shell_config=build_shell_layer_config(agent_soul),
session_snapshot=context.session_snapshot,
deferred_tool_results=context.deferred_tool_results,
idempotency_key=context.idempotency_key,
metadata=metadata,
)

View File

@ -56,6 +56,10 @@ class StoredAgentAppSession:
session_snapshot: CompositorSessionSnapshot
backend_run_id: str | None
runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list)
# ENG-635: set while the conversation turn is paused on a dify.ask_human
# deferred call, awaiting a HITL form submission.
pending_form_id: str | None = None
pending_tool_call_id: str | None = None
class AgentAppRuntimeSessionStore:
@ -75,6 +79,8 @@ class AgentAppRuntimeSessionStore:
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
backend_run_id=row.backend_run_id,
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
pending_form_id=row.pending_form_id,
pending_tool_call_id=row.pending_tool_call_id,
)
def load_active_session_for_conversation(
@ -125,6 +131,8 @@ class AgentAppRuntimeSessionStore:
backend_run_id: str,
snapshot: CompositorSessionSnapshot | None,
runtime_layer_specs: list[RuntimeLayerSpec],
pending_form_id: str | None = None,
pending_tool_call_id: str | None = None,
) -> None:
if snapshot is None:
return
@ -144,6 +152,8 @@ class AgentAppRuntimeSessionStore:
session_snapshot=snapshot_json,
composition_layer_specs=runtime_layer_specs_json,
status=AgentRuntimeSessionStatus.ACTIVE,
pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id,
)
session.add(row)
else:
@ -152,6 +162,9 @@ class AgentAppRuntimeSessionStore:
row.composition_layer_specs = runtime_layer_specs_json
row.status = AgentRuntimeSessionStatus.ACTIVE
row.cleaned_at = None
# Set (or clear, when omitted) the ask_human pause correlation.
row.pending_form_id = pending_form_id
row.pending_tool_call_id = pending_tool_call_id
session.flush()
other_rows = session.scalars(
select(AgentRuntimeSession).where(

View File

@ -12,6 +12,8 @@ from extensions.ext_redis import redis_client
marketplace_api_url = URL(str(dify_config.MARKETPLACE_API_URL))
logger = logging.getLogger(__name__)
MARKETPLACE_TIMEOUT = 30
def get_plugin_pkg_url(plugin_unique_identifier: str) -> str:
return str((marketplace_api_url / "api/v1/plugins/download").with_query(unique_identifier=plugin_unique_identifier))
@ -26,7 +28,12 @@ def batch_fetch_plugin_manifests(plugin_ids: list[str]) -> Sequence[MarketplaceP
return []
url = str(marketplace_api_url / "api/v1/plugins/batch")
response = httpx.post(url, json={"plugin_ids": plugin_ids}, headers={"X-Dify-Version": dify_config.project.version})
response = httpx.post(
url,
json={"plugin_ids": plugin_ids},
headers={"X-Dify-Version": dify_config.project.version},
timeout=MARKETPLACE_TIMEOUT,
)
response.raise_for_status()
return [MarketplacePluginDeclaration.model_validate(plugin) for plugin in response.json()["data"]["plugins"]]
@ -37,7 +44,12 @@ def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]:
return []
url = str(marketplace_api_url / "api/v1/plugins/batch")
response = httpx.post(url, json={"plugin_ids": plugin_ids}, headers={"X-Dify-Version": dify_config.project.version})
response = httpx.post(
url,
json={"plugin_ids": plugin_ids},
headers={"X-Dify-Version": dify_config.project.version},
timeout=MARKETPLACE_TIMEOUT,
)
response.raise_for_status()
data = response.json()
@ -46,7 +58,7 @@ def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]:
def record_install_plugin_event(plugin_unique_identifier: str):
url = str(marketplace_api_url / "api/v1/stats/plugins/install_count")
response = httpx.post(url, json={"unique_identifier": plugin_unique_identifier})
response = httpx.post(url, json={"unique_identifier": plugin_unique_identifier}, timeout=MARKETPLACE_TIMEOUT)
response.raise_for_status()
@ -64,7 +76,7 @@ def fetch_global_plugin_manifest(cache_key_prefix: str, cache_ttl: int) -> None:
Exception: If any other error occurs during fetching or caching
"""
url = str(marketplace_api_url / "api/v1/dist/plugins/manifest.json")
response = httpx.get(url, headers={"X-Dify-Version": dify_config.project.version}, timeout=30)
response = httpx.get(url, headers={"X-Dify-Version": dify_config.project.version}, timeout=MARKETPLACE_TIMEOUT)
response.raise_for_status()
raw_json = response.json()

View File

@ -168,6 +168,7 @@ class PluginInstallTask(BasePluginEntity):
class PluginInstallTaskStartResponse(BaseModel):
all_installed: bool = Field(description="Whether all plugins are installed.")
task_id: str = Field(description="The ID of the install task.")
task: PluginInstallTask | None = Field(default=None, description="The install task.")
class PluginVerification(BaseModel):
@ -206,6 +207,11 @@ class PluginListResponse(BaseModel):
total: int
class PluginListWithoutTotalResponse(BaseModel):
list: list[PluginEntity]
has_more: bool
class PluginDynamicSelectOptionsResponse(BaseModel):
options: Sequence[PluginParameterOption] = Field(description="The options of the dynamic select.")

View File

@ -6,6 +6,7 @@ from requests import HTTPError
from core.plugin.entities.bundle import PluginBundleDependency
from core.plugin.entities.plugin import (
MissingPluginDependency,
PluginCategory,
PluginDeclaration,
PluginEntity,
PluginInstallation,
@ -16,6 +17,7 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginReadmeResponse,
)
from core.plugin.impl.base import BasePluginClient
@ -74,6 +76,16 @@ class PluginInstaller(BasePluginClient):
params={"page": page, "page_size": page_size, "response_type": "paged"},
)
def list_plugins_by_category(
self, tenant_id: str, category: PluginCategory, page: int, page_size: int
) -> PluginListWithoutTotalResponse:
return self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/{category.value}/list",
PluginListWithoutTotalResponse,
params={"page": page, "page_size": page_size, "response_type": "paged"},
)
def upload_pkg(
self,
tenant_id: str,

View File

@ -31,6 +31,7 @@ from core.helper.marketplace import download_plugin_pkg
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
from core.plugin.entities.bundle import PluginBundleDependency
from core.plugin.entities.plugin import (
PluginCategory,
PluginDeclaration,
PluginEntity,
PluginInstallation,
@ -41,6 +42,7 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTask,
PluginInstallTaskStatus,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginModelProviderEntity,
PluginVerification,
)
@ -437,6 +439,19 @@ class PluginService:
PluginService._reconcile_endpoint_counts(tenant_id, user_id, plugins.list)
return plugins
@staticmethod
def list_by_category(
tenant_id: str, category: PluginCategory, page: int, page_size: int
) -> PluginListWithoutTotalResponse:
"""
List plugins in one category with a has-more cursor signal and without calculating total.
The daemon scans tenant installations in the existing list order and stops once it finds one extra match.
This keeps pagination usable before category is persisted on installation rows.
"""
manager = PluginInstaller()
return manager.list_plugins_by_category(tenant_id, category, page, page_size)
@staticmethod
def _normalize_endpoint_count(value: object) -> int:
"""Convert daemon endpoint counters to safe non-negative integers.

View File

@ -6,16 +6,13 @@ from graphon.file import File
def convert_parameters_to_plugin_format(parameters: dict[str, Any]) -> dict[str, Any]:
for parameter_name, parameter in parameters.items():
if isinstance(parameter, File):
parameters[parameter_name] = parameter.to_plugin_parameter()
elif isinstance(parameter, list) and all(isinstance(p, File) for p in parameter):
parameters[parameter_name] = []
for p in parameter:
parameters[parameter_name].append(p.to_plugin_parameter())
elif isinstance(parameter, ToolSelector):
parameters[parameter_name] = parameter.to_plugin_parameter()
elif isinstance(parameter, list) and all(isinstance(p, ToolSelector) for p in parameter):
parameters[parameter_name] = []
for p in parameter:
parameters[parameter_name].append(p.to_plugin_parameter())
match parameter:
case File():
parameters[parameter_name] = parameter.to_plugin_parameter()
case [*items] if all(isinstance(p, File) for p in items):
parameters[parameter_name] = [p.to_plugin_parameter() for p in items]
case ToolSelector():
parameters[parameter_name] = parameter.to_plugin_parameter()
case [*items] if all(isinstance(p, ToolSelector) for p in items):
parameters[parameter_name] = [p.to_plugin_parameter() for p in items]
return parameters

View File

@ -118,16 +118,18 @@ class WaterCrawlAPIClient(BaseAPIClient):
response.raise_for_status()
if response.status_code == 204:
return None
if response.headers.get("Content-Type") == "application/json":
content_type = response.headers.get("Content-Type", "")
media_type = content_type.split(";", 1)[0].strip().lower()
if media_type == "application/json":
return response.json() or {}
if response.headers.get("Content-Type") == "application/octet-stream":
if media_type == "application/octet-stream":
return response.content
if response.headers.get("Content-Type") == "text/event-stream":
if media_type == "text/event-stream":
return self.process_eventstream(response)
raise Exception(f"Unknown response type: {response.headers.get('Content-Type')}")
raise Exception(f"Unknown response type: {content_type}")
def get_crawl_requests_list(self, page: int | None = None, page_size: int | None = None):
query_params = {"page": page or 1, "page_size": page_size or 10}
@ -217,7 +219,7 @@ class WaterCrawlAPIClient(BaseAPIClient):
return event_data["data"]
def download_result(self, result_object: dict[str, Any]):
response = httpx.get(result_object["result"], timeout=None)
response = httpx.get(result_object["result"], timeout=30)
try:
response.raise_for_status()
result_object["result"] = response.json()

View File

@ -5,6 +5,8 @@ import re
import uuid
from typing import Any, TypedDict, cast, override
from sqlalchemy.orm import scoped_session
logger = logging.getLogger(__name__)
from sqlalchemy import select
@ -411,11 +413,13 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
if supports_vision:
# First, try to get images from SegmentAttachmentBinding (preferred method)
if segment_id:
image_files = ParagraphIndexProcessor._extract_images_from_segment_attachments(tenant_id, segment_id)
image_files = ParagraphIndexProcessor._extract_images_from_segment_attachments(
tenant_id, segment_id, db.session
)
# If no images from attachments, fall back to extracting from text
if not image_files:
image_files = ParagraphIndexProcessor._extract_images_from_text(tenant_id, text)
image_files = ParagraphIndexProcessor._extract_images_from_text(tenant_id, text, db.session)
# Build prompt messages
prompt_messages = []
@ -469,7 +473,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
return summary_content, usage
@staticmethod
def _extract_images_from_text(tenant_id: str, text: str) -> list[File]:
def _extract_images_from_text(tenant_id: str, text: str, session: scoped_session) -> list[File]:
"""
Extract images from markdown text and convert them to File objects.
@ -518,7 +522,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
# Get unique IDs for database query
unique_upload_file_ids = list(set(upload_file_id_list))
upload_files = db.session.scalars(
upload_files = session.scalars(
select(UploadFile).where(UploadFile.id.in_(unique_upload_file_ids), UploadFile.tenant_id == tenant_id)
).all()
@ -549,7 +553,9 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
return file_objects
@staticmethod
def _extract_images_from_segment_attachments(tenant_id: str, segment_id: str) -> list[File]:
def _extract_images_from_segment_attachments(
tenant_id: str, segment_id: str, session: scoped_session
) -> list[File]:
"""
Extract images from SegmentAttachmentBinding table (preferred method).
This matches how DatasetRetrieval gets segment attachments.
@ -564,7 +570,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
from sqlalchemy import select
# Query attachments from SegmentAttachmentBinding table
attachments_with_bindings = db.session.execute(
attachments_with_bindings = session.execute(
select(SegmentAttachmentBinding, UploadFile)
.join(UploadFile, UploadFile.id == SegmentAttachmentBinding.attachment_id)
.where(

View File

@ -63,6 +63,10 @@ class FormCreateParams:
display_in_ui: bool
resolved_default_values: Mapping[str, Any]
form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME
# ENG-635: the conversation this form belongs to. Set together with
# workflow_execution_id for chatflow runs; set alone (workflow_execution_id None)
# for Agent v2 chat ask_human forms, which have no workflow run.
conversation_id: str | None = None
class HumanInputFormRecipientEntity(Protocol):
@ -217,6 +221,9 @@ class HumanInputFormRecord:
recipient_id: str | None
recipient_type: RecipientType | None
access_token: str | None
# ENG-635: Agent v2 chat owner (NULL for workflow-owned forms). Trailing +
# defaulted so existing record constructions stay source-compatible.
conversation_id: str | None = None
@property
def submitted(self) -> bool:
@ -232,6 +239,7 @@ class HumanInputFormRecord:
return cls(
form_id=form_model.id,
workflow_run_id=form_model.workflow_run_id,
conversation_id=form_model.conversation_id,
node_id=form_model.node_id,
tenant_id=form_model.tenant_id,
app_id=form_model.app_id,
@ -433,8 +441,15 @@ class HumanInputFormRepositoryImpl:
if not app_id:
raise ValueError("app_id is required to create a human input form")
workflow_execution_id = params.workflow_execution_id or self._workflow_execution_id
if params.form_kind == HumanInputFormKind.RUNTIME and workflow_execution_id is None:
raise ValueError("workflow_execution_id is required for runtime human input forms")
# A RUNTIME form must be owned by at least one of: a workflow run (workflow /
# Human-Input / agent node) or a conversation turn (ENG-635: Agent v2 chat
# ask_human; chatflow runs set both — workflow_run_id and conversation_id).
if (
params.form_kind == HumanInputFormKind.RUNTIME
and workflow_execution_id is None
and params.conversation_id is None
):
raise ValueError("a runtime human input form requires a workflow_execution_id or conversation_id")
with session_factory.create_session() as session, session.begin():
# Generate unique form ID
@ -456,6 +471,7 @@ class HumanInputFormRepositoryImpl:
tenant_id=self._tenant_id,
app_id=app_id,
workflow_run_id=workflow_execution_id,
conversation_id=params.conversation_id,
form_kind=params.form_kind,
node_id=params.node_id,
form_definition=form_definition.model_dump_json(),

View File

@ -66,20 +66,21 @@ class Tool(ABC):
message_id=message_id,
)
if isinstance(result, ToolInvokeMessage):
match result:
case ToolInvokeMessage():
def single_generator() -> Generator[ToolInvokeMessage, None, None]:
yield result
def single_generator() -> Generator[ToolInvokeMessage, None, None]:
yield result
return single_generator()
elif isinstance(result, list):
return single_generator()
case list():
def generator() -> Generator[ToolInvokeMessage, None, None]:
yield from result
def generator() -> Generator[ToolInvokeMessage, None, None]:
yield from result
return generator()
else:
return result
return generator()
case _:
return result
def _transform_tool_parameters_type(self, tool_parameters: dict[str, Any]) -> dict[str, Any]:
"""

View File

@ -398,6 +398,8 @@ class ToolManager:
user_id: str | None = None,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: "VariablePool | None" = None,
allow_file_parameters: bool = False,
use_default_for_missing_form_parameters: bool = False,
) -> Tool:
"""
get the agent tool runtime
@ -415,7 +417,12 @@ class ToolManager:
runtime_parameters: dict[str, Any] = {}
parameters = tool_entity.get_merged_runtime_parameters()
runtime_parameters = cls._convert_tool_parameters_type(
parameters, variable_pool, agent_tool.tool_parameters, typ="agent"
parameters,
variable_pool,
agent_tool.tool_parameters,
typ="agent",
allow_file_parameters=allow_file_parameters,
use_default_for_missing_form_parameters=use_default_for_missing_form_parameters,
)
# decrypt runtime parameters
encryption_manager = ToolParameterConfigurationManager(
@ -1063,6 +1070,8 @@ class ToolManager:
variable_pool: "VariablePool | None",
tool_configurations: Mapping[str, Any],
typ: Literal["agent", "workflow", "tool"] = "workflow",
allow_file_parameters: bool = False,
use_default_for_missing_form_parameters: bool = False,
) -> dict[str, Any]:
"""
Convert tool parameters type
@ -1081,6 +1090,7 @@ class ToolManager:
}
and parameter.required
and typ == "agent"
and not allow_file_parameters
):
raise ValueError(f"file type parameter {parameter.name} not supported in agent")
# save tool parameter to tool entity memory
@ -1117,7 +1127,19 @@ class ToolManager:
runtime_parameters[parameter.name] = parameter_value
else:
value = parameter.init_frontend_parameter(tool_configurations.get(parameter.name))
parameter_value = tool_configurations.get(parameter.name)
if use_default_for_missing_form_parameters and parameter_value is None:
if parameter.default is not None:
parameter_value = parameter.default
elif (
parameter.required
and parameter.type == ToolParameter.ToolParameterType.SELECT
and parameter.options
):
parameter_value = parameter.options[0].value
else:
continue
value = parameter.init_frontend_parameter(parameter_value)
runtime_parameters[parameter.name] = value
return runtime_parameters

View File

@ -334,6 +334,7 @@ class DifyNodeFactory(NodeFactory):
self.graph_runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
),
conversation_id_getter=self._conversation_id,
)
self._tool_runtime = DifyToolNodeRuntime(self._dify_context)
self._http_request_file_manager = file_manager

View File

@ -710,10 +710,12 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol):
run_context: Mapping[str, Any] | DifyRunContext,
*,
workflow_execution_id_getter: Callable[[], str | None] | None = None,
conversation_id_getter: Callable[[], str | None] | None = None,
form_repository: HumanInputFormRepository | None = None,
) -> None:
self._run_context = resolve_dify_run_context(run_context)
self._workflow_execution_id_getter = workflow_execution_id_getter
self._conversation_id_getter = conversation_id_getter
self._form_repository = form_repository
self._file_reference_factory = DifyFileReferenceFactory(self._run_context)
@ -762,6 +764,7 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol):
return DifyHumanInputNodeRuntime(
self._run_context,
workflow_execution_id_getter=self._workflow_execution_id_getter,
conversation_id_getter=self._conversation_id_getter,
form_repository=form_repository,
)
@ -799,6 +802,9 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol):
repo = self.build_form_repository()
params = FormCreateParams(
workflow_execution_id=self._workflow_execution_id_getter() if self._workflow_execution_id_getter else None,
# A chatflow (advanced-chat) run carries a conversation; tag the form with
# it too so it is queryable per conversation. None for a pure workflow run.
conversation_id=self._conversation_id_getter() if self._conversation_id_getter else None,
node_id=node_id,
form_config=node_data,
rendered_content=rendered_content,

View File

@ -24,13 +24,16 @@ from clients.agent_backend import (
extract_runtime_layer_specs,
)
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.entities.pause_reason import SchedulingPause
from graphon.entities.pause_reason import HumanInputRequired, SchedulingPause
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent
from graphon.nodes.base.node import Node
from models.agent_config_entities import WorkflowNodeJobConfig
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason
from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
from .binding_resolver import WorkflowAgentBindingError, WorkflowAgentBindingResolver
from .entities import DifyAgentNodeData
from .output_adapter import WorkflowAgentOutputAdapter
@ -117,6 +120,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
self.graph_runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
)
# Set on chatflow (advanced-chat) runs; None for a pure workflow run. Lets an
# ask_human form be tagged with its conversation in addition to workflow_run_id.
conversation_id = get_system_text(
self.graph_runtime_state.variable_pool,
SystemVariableKey.CONVERSATION_ID,
)
inputs: dict[str, Any] = {}
process_data: dict[str, Any] = {}
metadata: dict[str, Any] = {
@ -168,6 +177,33 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
)
outputs_by_name = {o.name: o for o in effective_outputs}
# ──── ENG-638: resume after a submitted/timed-out ask_human form ────
# graphon re-executes this _run when the outer workflow resumes. If a
# pending ask_human form is now terminal, thread the human's answer into
# the second Agent run as deferred_tool_results; if it is somehow still
# waiting, re-emit the same pause defensively.
deferred_tool_results = None
if self._session_store is not None:
stored_session = self._session_store.load_active_session(session_scope)
if stored_session is not None and stored_session.pending_form_id is not None:
resume_outcome = resolve_ask_human_form(
form_id=stored_session.pending_form_id,
tenant_id=dify_ctx.tenant_id,
node_id=self._node_id,
)
if resume_outcome is not None and resume_outcome.repause is not None:
yield PauseRequestedEvent(reason=resume_outcome.repause)
return
if (
resume_outcome is not None
and resume_outcome.deferred_result is not None
and stored_session.pending_tool_call_id is not None
):
deferred_tool_results = build_deferred_tool_results(
tool_call_id=stored_session.pending_tool_call_id,
result=resume_outcome.deferred_result,
)
# ──── Retry loop (Stage 4 §7) ────
attempt = 0
while True:
@ -188,6 +224,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
snapshot=bundle.snapshot,
attempt=attempt,
session_snapshot=session_snapshot,
deferred_tool_results=deferred_tool_results,
)
)
except WorkflowAgentRuntimeRequestBuildError as error:
@ -251,19 +288,56 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
return
if isinstance(terminal_event, AgentBackendDeferredToolCallInternalEvent):
# ENG-636: a dify.ask_human deferred call pauses the *outer*
# workflow through the existing HITL form path. Any other deferred
# tool (none today) falls back to a generic scheduling pause. The
# form is built *before* the snapshot is saved so its id can be
# persisted as the pause correlation (ENG-637).
try:
pause_reason: HumanInputRequired | SchedulingPause | None = build_ask_human_pause_reason(
deferred_tool_call=terminal_event.deferred_tool_call,
node_id=self._node_id,
default_node_title=bundle.agent.name or self._node_id,
workflow_run_id=workflow_run_id,
conversation_id=conversation_id,
contacts=AgentSoulConfig.model_validate(bundle.snapshot.config_snapshot_dict).human.contacts,
repository=self._build_human_input_form_repository(
dify_ctx=dify_ctx, workflow_run_id=workflow_run_id
),
)
except AskHumanFormBuildError as error:
yield self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
error=str(error),
error_type="agent_ask_human_form_build_error",
)
return
# ENG-637: persist the awaiting-form id + deferred tool_call id
# next to the snapshot so the resumed node can rebuild
# deferred_tool_results from the submitted form.
pending_form_id: str | None = None
pending_tool_call_id: str | None = None
if isinstance(pause_reason, HumanInputRequired):
pending_form_id = pause_reason.form_id
pending_tool_call_id = terminal_event.deferred_tool_call.tool_call_id
else:
pause_reason = SchedulingPause(
message=terminal_event.message
or "Agent backend run requested workflow pause for external input."
)
self._save_session_snapshot(
session_scope=session_scope,
backend_run_id=terminal_event.run_id,
snapshot=terminal_event.session_snapshot,
runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition),
metadata=metadata,
pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id,
)
yield PauseRequestedEvent(
reason=SchedulingPause(
message=terminal_event.message
or "Agent backend run requested workflow pause for external input."
)
)
yield PauseRequestedEvent(reason=pause_reason)
return
# Non-success terminal (failed / cancelled) skips per-output
@ -448,6 +522,27 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
],
}
def _build_human_input_form_repository(
self,
*,
dify_ctx: DifyRunContext,
workflow_run_id: str | None,
) -> HumanInputFormRepository:
"""Construct the existing HITL form repository for ask_human form creation.
Mirrors the Human Input node's repository wiring (``node_runtime``) so the
ask_human form shares the same delivery/debug/console behavior: a
submission actor is only attributed for debugger/explore surfaces.
"""
invoke_source = dify_ctx.invoke_from.value
return HumanInputFormRepositoryImpl(
tenant_id=dify_ctx.tenant_id,
app_id=dify_ctx.app_id,
workflow_execution_id=workflow_run_id,
invoke_source=invoke_source,
submission_actor_id=dify_ctx.user_id if invoke_source in {"debugger", "explore"} else None,
)
def _save_session_snapshot(
self,
*,
@ -456,6 +551,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
snapshot: CompositorSessionSnapshot | None,
runtime_layer_specs: list[RuntimeLayerSpec],
metadata: dict[str, Any],
pending_form_id: str | None = None,
pending_tool_call_id: str | None = None,
) -> None:
if self._session_store is None:
return
@ -465,6 +562,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
backend_run_id=backend_run_id,
snapshot=snapshot,
runtime_layer_specs=runtime_layer_specs,
pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id,
)
agent_backend = dict(metadata.get("agent_backend") or {})
agent_backend["session_snapshot_persisted"] = snapshot is not None

View File

@ -0,0 +1,354 @@
"""Map a ``dify.ask_human`` deferred tool call onto the existing HITL form path.
ENG-636. When an Agent backend run ends with a deferred ``dify.ask_human`` tool
call, the workflow Agent node pauses the *outer* workflow through the very same
``HumanInputRequired`` form mechanism the Human Input node uses reusing the
form repository, delivery channels, and submission endpoints unchanged. This
module is a pure translation layer: model-facing ask_human tool args become
graphon form entities (``HumanInputNodeData`` / ``FormInputConfig`` /
``UserActionConfig``) plus Dify delivery configs. It adds no new HITL behavior.
The agent-side ``dify.ask_human`` contract is richer than the workflow form
schema in two places, handled here without widening the form vocabulary:
* ask_human fields carry a human label; graphon ``FormInputConfig`` does not.
Labels are rendered into ``form_content`` next to a ``{{#$output.<name>#}}``
marker exactly how the Human Input node positions labelled inputs today.
* ask_human action ids are valid identifiers of any length; graphon
``UserActionConfig.id`` caps ids at 20 chars. Over-long ids are clamped
deterministically (stable + collision-resistant) so the form always builds;
the human-visible action *label* is always preserved verbatim.
"""
from __future__ import annotations
import hashlib
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, assert_never
from dify_agent.layers.ask_human import (
AskHumanAction,
AskHumanField,
AskHumanFileField,
AskHumanFileListField,
AskHumanParagraphField,
AskHumanSelectField,
AskHumanToolArgs,
)
from dify_agent.protocol import DeferredToolCallPayload
from pydantic import ValidationError
from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepository
from core.workflow.human_input_adapter import (
DeliveryChannelConfig,
EmailDeliveryConfig,
EmailDeliveryMethod,
EmailRecipients,
ExternalRecipient,
InteractiveSurfaceDeliveryMethod,
)
from graphon.entities.pause_reason import HumanInputRequired
from graphon.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
FormInputConfig,
HumanInputNodeData,
ParagraphInputConfig,
SelectInputConfig,
StringListSource,
StringSource,
UserActionConfig,
)
from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType
from models.agent_config_entities import AgentHumanContactConfig
# Default ask_human tool name (see ``DifyAskHumanLayerConfig.tool_name``). The
# Agent node only knows how to translate this one deferred tool into a form.
DEFAULT_ASK_HUMAN_TOOL_NAME = "ask_human"
# Graphon ``UserActionConfig.id`` hard-caps identifiers at 20 chars.
_MAX_ACTION_ID_LEN = 20
# No timeout lives in the ask_human contract; reuse the Human Input node default.
_DEFAULT_TIMEOUT_HOURS = 36
_EMAIL_SUBJECT_MAX_LEN = 120
# ``ButtonStyle`` has no "destructive"; map the ask_human destructive intent onto
# the closest existing form style rather than extending the HITL vocabulary.
_ACTION_STYLE_TO_BUTTON: dict[str, ButtonStyle] = {
"default": ButtonStyle.DEFAULT,
"primary": ButtonStyle.PRIMARY,
"destructive": ButtonStyle.ACCENT,
}
# ask_human permits zero actions (a plain acknowledgement); the form surface
# needs at least one submit affordance.
_DEFAULT_SUBMIT_ACTION = UserActionConfig(id="submit", title="Submit", button_style=ButtonStyle.PRIMARY)
class AskHumanFormBuildError(ValueError):
"""Raised when ask_human tool args cannot be mapped to a HITL form."""
def parse_ask_human_args(raw_args: object) -> AskHumanToolArgs:
"""Validate the raw deferred-tool ``args`` payload into ``AskHumanToolArgs``."""
if isinstance(raw_args, AskHumanToolArgs):
return raw_args
if isinstance(raw_args, Mapping):
try:
return AskHumanToolArgs.model_validate(dict(raw_args))
except ValidationError as error:
raise AskHumanFormBuildError(f"invalid ask_human args: {error}") from error
raise AskHumanFormBuildError(f"ask_human args must be a mapping, got {type(raw_args).__name__}")
def _clamp_action_id(action_id: str) -> str:
"""Return a stable graphon-safe (<= 20 char) action id.
ask_human ids are already valid identifiers; ids within the limit pass
through untouched so the resume round-trip returns the model's exact id.
Over-long ids are truncated with a short content hash to stay deterministic
and collision-resistant while remaining a valid identifier.
"""
if len(action_id) <= _MAX_ACTION_ID_LEN:
return action_id
digest = hashlib.blake2b(action_id.encode("utf-8"), digest_size=3).hexdigest()
prefix = action_id[: _MAX_ACTION_ID_LEN - len(digest) - 1]
return f"{prefix}_{digest}"
def _to_form_input(field: AskHumanField) -> FormInputConfig:
match field:
case AskHumanParagraphField():
default = (
StringSource(type=ValueSourceType.CONSTANT, value=field.default) if field.default is not None else None
)
return ParagraphInputConfig(output_variable_name=field.name, default=default)
case AskHumanSelectField():
return SelectInputConfig(
output_variable_name=field.name,
option_source=StringListSource(
type=ValueSourceType.CONSTANT,
value=[option.value for option in field.options],
),
)
case AskHumanFileField():
return FileInputConfig(output_variable_name=field.name)
case AskHumanFileListField():
return FileListInputConfig(output_variable_name=field.name, number_limits=field.max_files or 0)
case _: # pragma: no cover - exhaustive over the discriminated union
assert_never(field)
def _to_user_actions(actions: Sequence[AskHumanAction]) -> list[UserActionConfig]:
if not actions:
return [_DEFAULT_SUBMIT_ACTION]
return [
UserActionConfig(
id=_clamp_action_id(action.id),
title=action.label,
button_style=_ACTION_STYLE_TO_BUTTON.get(action.style, ButtonStyle.DEFAULT),
)
for action in actions
]
def _render_form_content(args: AskHumanToolArgs) -> str:
"""Compose the markdown body, positioning each field's label + input marker.
Graphon ``FormInputConfig`` carries no label, so the field label is written
into the content next to the ``{{#$output.<name>#}}`` marker that the form
surface replaces with the live input identical to the Human Input node.
"""
blocks: list[str] = []
if args.title:
blocks.append(f"## {args.title}")
blocks.append(args.question)
if args.markdown:
blocks.append(args.markdown)
for field in args.fields:
label = f"{field.label} *" if field.required else field.label
blocks.append(f"**{label}**\n\n{{{{#$output.{field.name}#}}}}")
return "\n\n".join(blocks)
def _resolved_default_values(args: AskHumanToolArgs) -> dict[str, Any]:
"""Pre-fill map the form surface reads, keyed by output variable name.
The graphon select input has no default field, so a select default can only
be conveyed here; paragraph defaults are included for a uniform pre-fill.
"""
defaults: dict[str, Any] = {}
for field in args.fields:
if isinstance(field, AskHumanParagraphField | AskHumanSelectField) and field.default is not None:
defaults[field.name] = field.default
return defaults
def ask_human_args_to_node_data(args: AskHumanToolArgs, *, node_title: str) -> HumanInputNodeData:
"""Translate validated ask_human args into a synthetic Human Input node config."""
return HumanInputNodeData(
title=node_title,
form_content=_render_form_content(args),
inputs=[_to_form_input(field) for field in args.fields],
user_actions=_to_user_actions(args.actions),
timeout=_DEFAULT_TIMEOUT_HOURS,
timeout_unit=TimeoutUnit.HOUR,
)
def build_delivery_methods(
contacts: Sequence[AgentHumanContactConfig],
*,
args: AskHumanToolArgs,
) -> list[DeliveryChannelConfig]:
"""Build form delivery channels: always the interactive surface, plus email to
the configured human contacts (the recipients chosen in Agent Soul)."""
methods: list[DeliveryChannelConfig] = [InteractiveSurfaceDeliveryMethod()]
seen: set[str] = set()
emails: list[str] = []
for contact in contacts:
email = (contact.email or "").strip()
if email and email not in seen:
seen.add(email)
emails.append(email)
if emails:
subject = (args.title or args.question).strip()[:_EMAIL_SUBJECT_MAX_LEN]
if args.urgency == "high":
subject = f"[Action needed] {subject}"
body = f"{args.question}\n\nOpen the request: {EmailDeliveryConfig.URL_PLACEHOLDER}"
methods.append(
EmailDeliveryMethod(
config=EmailDeliveryConfig(
recipients=EmailRecipients(items=[ExternalRecipient(email=email) for email in emails]),
subject=subject,
body=body,
)
)
)
return methods
@dataclass(frozen=True, slots=True)
class AskHumanFormCreated:
"""A created ask_human HITL form, owner-agnostic (workflow run or conversation)."""
form_id: str
args: AskHumanToolArgs
node_data: HumanInputNodeData
node_title: str
resolved_default_values: dict[str, Any]
def create_ask_human_form(
*,
deferred_tool_call: DeferredToolCallPayload,
node_id: str,
default_node_title: str,
contacts: Sequence[AgentHumanContactConfig],
repository: HumanInputFormRepository,
workflow_run_id: str | None = None,
conversation_id: str | None = None,
display_in_ui: bool = True,
) -> AskHumanFormCreated:
"""Create a HITL form from an ask_human deferred call (caller verified tool_name).
The form is owned by exactly one of ``workflow_run_id`` (workflow Agent node)
or ``conversation_id`` (Agent v2 chat). Raises ``AskHumanFormBuildError`` on
invalid args, a missing owner, or a repository failure.
"""
if not workflow_run_id and not conversation_id:
raise AskHumanFormBuildError("an ask_human HITL form requires a workflow_run_id or conversation_id")
args = parse_ask_human_args(deferred_tool_call.args)
node_title = args.title or default_node_title
node_data = ask_human_args_to_node_data(args, node_title=node_title)
resolved_default_values = _resolved_default_values(args)
try:
form = repository.create_form(
FormCreateParams(
workflow_execution_id=workflow_run_id,
conversation_id=conversation_id,
node_id=node_id,
form_config=node_data,
# No workflow-variable placeholders to resolve — the content is
# fully model-authored, so rendered == template.
rendered_content=node_data.form_content,
delivery_methods=build_delivery_methods(contacts, args=args),
display_in_ui=display_in_ui,
resolved_default_values=resolved_default_values,
)
)
except ValueError as error:
raise AskHumanFormBuildError(f"failed to create ask_human HITL form: {error}") from error
return AskHumanFormCreated(
form_id=form.id,
args=args,
node_data=node_data,
node_title=node_title,
resolved_default_values=resolved_default_values,
)
def build_ask_human_pause_reason(
*,
deferred_tool_call: DeferredToolCallPayload,
node_id: str,
default_node_title: str,
workflow_run_id: str | None,
contacts: Sequence[AgentHumanContactConfig],
repository: HumanInputFormRepository,
conversation_id: str | None = None,
expected_tool_name: str = DEFAULT_ASK_HUMAN_TOOL_NAME,
display_in_ui: bool = True,
) -> HumanInputRequired | None:
"""Create a workflow HITL form for an ask_human call and return its pause reason.
Returns ``None`` when the deferred call is *not* the ask_human tool, letting
the caller fall back to a generic scheduling pause. Raises
``AskHumanFormBuildError`` when the call is ask_human but its args or the form
cannot be built the caller should surface that as a node failure rather
than a silent, unresumable pause.
"""
if deferred_tool_call.tool_name != expected_tool_name:
return None
if not workflow_run_id:
raise AskHumanFormBuildError("workflow_run_id is required to create an ask_human HITL form")
created = create_ask_human_form(
deferred_tool_call=deferred_tool_call,
node_id=node_id,
default_node_title=default_node_title,
contacts=contacts,
repository=repository,
workflow_run_id=workflow_run_id,
# A chatflow agent node also belongs to a conversation; tag the form so it is
# queryable per conversation. None for a pure workflow run (workflow_run_id only).
conversation_id=conversation_id,
display_in_ui=display_in_ui,
)
return HumanInputRequired(
form_id=created.form_id,
form_content=created.node_data.form_content,
inputs=list(created.node_data.inputs),
actions=list(created.node_data.user_actions),
node_id=node_id,
node_title=created.node_title,
resolved_default_values=created.resolved_default_values,
)
__all__ = [
"DEFAULT_ASK_HUMAN_TOOL_NAME",
"AskHumanFormBuildError",
"AskHumanFormCreated",
"ask_human_args_to_node_data",
"build_ask_human_pause_reason",
"build_delivery_methods",
"create_ask_human_form",
"parse_ask_human_args",
]

View File

@ -0,0 +1,159 @@
"""Resume an Agent run after a dify.ask_human HITL form reaches a terminal state.
ENG-638. When the outer workflow resumes (the human submitted the form, or it
timed out), graphon re-executes the Agent node's ``_run``. This module reads the
correlated HITL form (by ``pending_form_id``) and maps it back into the
agent-side ``dify.ask_human`` contract so the node can start a *second* Agent run
that carries the human's answer:
* submitted -> AskHumanToolResult(status="submitted", action, values)
* timeout / expired -> AskHumanToolResult(status="timeout")
* still waiting (defensive: the host resumed us early) -> re-emit the same
HumanInputRequired pause rebuilt from the stored form definition.
It only *reads* existing HITL form state it never mutates the form or the HITL
submission flow. The DB read (``resolve_ask_human_form``) is kept thin so the
mapping (``map_form_to_outcome``) stays pure and unit-testable.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from dify_agent.layers.ask_human import (
AskHumanResultStatus,
AskHumanSelectedAction,
AskHumanToolResult,
)
from dify_agent.protocol import DeferredToolResultsPayload
from pydantic import JsonValue
from sqlalchemy import select
from core.db.session_factory import session_factory
from graphon.entities.pause_reason import HumanInputRequired
from graphon.nodes.human_input.entities import FormDefinition
from graphon.nodes.human_input.enums import HumanInputFormStatus
from models.human_input import HumanInputForm
# A WAITING form has not been answered yet; the other terminal states map onto
# the agent-facing result status. EXPIRED (global timeout) and TIMEOUT
# (node-level) both surface to the model as "timeout" so it can react.
_FORM_STATUS_TO_RESULT: dict[HumanInputFormStatus, AskHumanResultStatus] = {
HumanInputFormStatus.SUBMITTED: "submitted",
HumanInputFormStatus.TIMEOUT: "timeout",
HumanInputFormStatus.EXPIRED: "timeout",
}
@dataclass(frozen=True, slots=True)
class AskHumanResumeOutcome:
"""Result of inspecting a correlated ask_human form on workflow resume.
Exactly one of ``deferred_result`` / ``repause`` is set:
* ``deferred_result`` the form reached a terminal state; start the second run.
* ``repause`` the form is still waiting; re-emit this pause defensively.
"""
deferred_result: AskHumanToolResult | None = None
repause: HumanInputRequired | None = None
def resolve_ask_human_form(*, form_id: str, tenant_id: str, node_id: str) -> AskHumanResumeOutcome | None:
"""Load the correlated form and map it to a resume outcome.
Returns ``None`` when the form no longer exists (correlation lost) the
caller should fall back to a normal (non-resume) Agent run.
"""
with session_factory.create_session() as session:
form = session.scalar(
select(HumanInputForm).where(
HumanInputForm.id == form_id,
HumanInputForm.tenant_id == tenant_id,
)
)
if form is None:
return None
return map_form_to_outcome(
status=form.status,
selected_action_id=form.selected_action_id,
submitted_data=form.submitted_data,
rendered_content=form.rendered_content,
form_definition=form.form_definition,
form_id=form.id,
node_id=node_id,
)
def map_form_to_outcome(
*,
status: HumanInputFormStatus,
selected_action_id: str | None,
submitted_data: str | None,
rendered_content: str,
form_definition: str,
form_id: str,
node_id: str,
) -> AskHumanResumeOutcome:
"""Map a terminal (or still-waiting) HITL form to a resume outcome. Pure."""
definition = FormDefinition.model_validate_json(form_definition)
if status == HumanInputFormStatus.WAITING:
return AskHumanResumeOutcome(repause=_rebuild_pause(definition=definition, form_id=form_id, node_id=node_id))
result_status = _FORM_STATUS_TO_RESULT.get(status, "unavailable")
if result_status != "submitted":
return AskHumanResumeOutcome(deferred_result=AskHumanToolResult(status=result_status))
return AskHumanResumeOutcome(
deferred_result=AskHumanToolResult(
status="submitted",
action=_selected_action(selected_action_id=selected_action_id, definition=definition),
values=_submitted_values(submitted_data),
rendered_content=rendered_content,
)
)
def build_deferred_tool_results(*, tool_call_id: str, result: AskHumanToolResult) -> DeferredToolResultsPayload:
"""Wrap an ask_human result as the deferred-tool-results payload for resume."""
return DeferredToolResultsPayload(calls={tool_call_id: result.model_dump(mode="json")})
def _submitted_values(submitted_data: str | None) -> dict[str, JsonValue]:
if not submitted_data:
return {}
parsed = json.loads(submitted_data)
if not isinstance(parsed, dict):
return {}
return {str(key): value for key, value in parsed.items()}
def _selected_action(*, selected_action_id: str | None, definition: FormDefinition) -> AskHumanSelectedAction | None:
if selected_action_id is None:
return None
# The form's user_action title is the verbatim ask_human action label set at
# form-build time; fall back to the id only if the action is somehow missing.
label = next(
(action.title for action in definition.user_actions if action.id == selected_action_id),
selected_action_id,
)
return AskHumanSelectedAction(id=selected_action_id, label=label)
def _rebuild_pause(*, definition: FormDefinition, form_id: str, node_id: str) -> HumanInputRequired:
return HumanInputRequired(
form_id=form_id,
form_content=definition.rendered_content or definition.form_content,
inputs=list(definition.inputs),
actions=list(definition.user_actions),
node_id=node_id,
node_title=definition.node_title or node_id,
resolved_default_values=dict(definition.default_values),
)
__all__ = [
"AskHumanResumeOutcome",
"build_deferred_tool_results",
"map_form_to_outcome",
"resolve_ask_human_form",
]

View File

@ -42,6 +42,8 @@ class AgentToolRuntimeProvider(Protocol):
user_id: str | None = None,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: Any | None = None,
allow_file_parameters: bool = False,
use_default_for_missing_form_parameters: bool = False,
) -> Tool: ...
@ -176,6 +178,8 @@ class WorkflowAgentPluginToolsBuilder:
user_id=user_id,
invoke_from=invoke_from,
variable_pool=None,
allow_file_parameters=True,
use_default_for_missing_form_parameters=True,
)
except ToolProviderNotFoundError as exc:
raise WorkflowAgentPluginToolsBuildError(

View File

@ -18,13 +18,15 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
# ENG-623: exposed at runtime as the dify.drive declaration layer
# (an index the agent pulls through the back proxy).
"skills_files",
# ENG-635: human involvement is exposed at runtime as the dify.ask_human
# deferred tool; a call pauses via the existing HITL form mechanism.
"human",
}
)
RESERVED_AGENT_BACKEND_FEATURES = frozenset(
{
"knowledge",
"human",
"memory",
}
)
@ -85,6 +87,7 @@ def build_runtime_feature_manifest(
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
reserved_status["env"] = "supported_by_shell_bootstrap"
reserved_status["sandbox"] = "forwarded_to_shell_layer_config"
reserved_status["human"] = "supported_by_ask_human_hitl" if agent_soul.human.contacts else "not_configured"
return {
"supported": sorted(SUPPORTED_AGENT_BACKEND_FEATURES),

View File

@ -5,6 +5,7 @@ from dataclasses import dataclass
from typing import Any, Literal, Protocol, assert_never, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.ask_human import DifyAskHumanLayerConfig
from dify_agent.layers.drive import (
DifyDriveFileConfig,
DifyDriveLayerConfig,
@ -22,7 +23,7 @@ from dify_agent.layers.shell import (
DifyShellSandboxConfig,
DifyShellSecretRefConfig,
)
from dify_agent.protocol import CreateRunRequest
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
from pydantic import BaseModel
from clients.agent_backend import (
@ -41,6 +42,7 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
@ -103,6 +105,9 @@ class WorkflowAgentRuntimeBuildContext:
# idempotency key so the backend treats each retry as a fresh request.
attempt: int = 0
session_snapshot: CompositorSessionSnapshot | None = None
# ENG-638: set when resuming after a submitted ask_human HITL form; threads
# the human's answer back into the second Agent run keyed by tool_call_id.
deferred_tool_results: DeferredToolResultsPayload | None = None
@dataclass(frozen=True, slots=True)
@ -217,9 +222,11 @@ class WorkflowAgentRuntimeRequestBuilder:
output=self._build_output_config(node_job.declared_outputs),
tools=tools_layer,
drive_config=drive_config,
ask_human_config=build_ask_human_layer_config(agent_soul),
include_shell=dify_config.AGENT_SHELL_ENABLED,
shell_config=build_shell_layer_config(agent_soul),
session_snapshot=context.session_snapshot,
deferred_tool_results=context.deferred_tool_results,
idempotency_key=self._idempotency_key(context),
metadata=metadata,
)
@ -389,7 +396,11 @@ class WorkflowAgentRuntimeRequestBuilder:
@staticmethod
def _schema_for_declared_output(output: DeclaredOutputConfig) -> dict[str, Any]:
schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(output.type, array_item=output.array_item)
schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(
output.type,
array_item=output.array_item,
children=output.children,
)
if output.description:
schema["description"] = output.description
return schema
@ -399,6 +410,7 @@ class WorkflowAgentRuntimeRequestBuilder:
output_type: DeclaredOutputType,
*,
array_item: DeclaredArrayItem | None = None,
children: Sequence[DeclaredOutputChildConfig] | None = None,
) -> dict[str, Any]:
match output_type:
case DeclaredOutputType.STRING:
@ -408,18 +420,23 @@ class WorkflowAgentRuntimeRequestBuilder:
case DeclaredOutputType.BOOLEAN:
return {"type": "boolean"}
case DeclaredOutputType.OBJECT:
return {"type": "object"}
object_schema: dict[str, Any] = {"type": "object"}
WorkflowAgentRuntimeRequestBuilder._apply_child_properties(object_schema, children or [])
return object_schema
case DeclaredOutputType.ARRAY:
# Stage 4 §4.2: items shape mirrors the declared array_item.
# Validator guarantees array_item is set when type is array.
item_type = array_item.type if array_item else DeclaredOutputType.OBJECT
schema: dict[str, Any] = {
array_schema: dict[str, Any] = {
"type": "array",
"items": WorkflowAgentRuntimeRequestBuilder._schema_for_type(item_type),
"items": WorkflowAgentRuntimeRequestBuilder._schema_for_type(
item_type,
children=array_item.children if array_item else None,
),
}
if array_item is not None and array_item.description:
schema["items"]["description"] = array_item.description
return schema
array_schema["items"]["description"] = array_item.description
return array_schema
case DeclaredOutputType.FILE:
return {
"oneOf": [
@ -463,6 +480,27 @@ class WorkflowAgentRuntimeRequestBuilder:
}
assert_never(output_type)
@staticmethod
def _apply_child_properties(schema: dict[str, Any], children: Sequence[DeclaredOutputChildConfig]) -> None:
if not children:
return
properties: dict[str, Any] = {}
required: list[str] = []
for child in children:
child_schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(
child.type,
array_item=child.array_item,
children=child.children,
)
if child.description:
child_schema["description"] = child.description
properties[child.name] = child_schema
if child.required:
required.append(child.name)
schema["properties"] = properties
if required:
schema["required"] = required
@staticmethod
def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]:
normalized: dict[str, str | int | float | bool | None] = {}
@ -496,6 +534,20 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
)
def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None:
"""Enable the dify.ask_human deferred tool when the soul configures human involvement.
HITL is opt-in: only when at least one human contact is configured does the
model get the ``ask_human`` tool (recipients for the resulting form come from
those contacts, ENG-635). Returns ``None`` to leave the tool off entirely.
The tool/field guardrails use the layer defaults; ``human.tools`` semantics are
out of scope this round.
"""
if not agent_soul.human.contacts:
return None
return DifyAskHumanLayerConfig()
def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, str]]) -> None:
"""Merge build-time warnings into the metadata runtime-support manifest."""
if not warnings:
@ -649,7 +701,13 @@ def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None:
name = _name_from_mapping(data)
if name is None:
return None
ref = data.get("ref") or data.get("id") or data.get("credential_id") or data.get("provider_credential_id")
ref = (
data.get("ref")
or data.get("value")
or data.get("id")
or data.get("credential_id")
or data.get("provider_credential_id")
)
return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None)

View File

@ -47,12 +47,20 @@ class StoredWorkflowAgentSession:
session_snapshot: CompositorSessionSnapshot
backend_run_id: str | None
runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list)
# ENG-637: set while the session is paused on a dify.ask_human deferred call.
pending_form_id: str | None = None
pending_tool_call_id: str | None = None
class WorkflowAgentRuntimeSessionStore:
"""Stores Agent backend session snapshots for workflow Agent node re-entry."""
def load_active_snapshot(self, scope: WorkflowAgentSessionScope) -> CompositorSessionSnapshot | None:
stored = self.load_active_session(scope)
return stored.session_snapshot if stored is not None else None
def load_active_session(self, scope: WorkflowAgentSessionScope) -> StoredWorkflowAgentSession | None:
"""Load the active session row including any pending ask_human correlation."""
if scope.workflow_run_id is None:
return None
@ -69,7 +77,14 @@ class WorkflowAgentRuntimeSessionStore:
)
if row is None:
return None
return CompositorSessionSnapshot.model_validate_json(row.session_snapshot)
return StoredWorkflowAgentSession(
scope=scope,
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
backend_run_id=row.backend_run_id,
runtime_layer_specs=_deserialize_specs(row.composition_layer_specs),
pending_form_id=row.pending_form_id,
pending_tool_call_id=row.pending_tool_call_id,
)
def list_active_sessions(self, *, workflow_run_id: str) -> list[StoredWorkflowAgentSession]:
with session_factory.create_session() as session:
@ -109,6 +124,8 @@ class WorkflowAgentRuntimeSessionStore:
backend_run_id: str,
snapshot: CompositorSessionSnapshot | None,
runtime_layer_specs: list[RuntimeLayerSpec],
pending_form_id: str | None = None,
pending_tool_call_id: str | None = None,
) -> None:
if scope.workflow_run_id is None or snapshot is None:
return
@ -141,6 +158,8 @@ class WorkflowAgentRuntimeSessionStore:
session_snapshot=snapshot_json,
composition_layer_specs=specs_json,
status=WorkflowAgentRuntimeSessionStatus.ACTIVE,
pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id,
)
session.add(row)
else:
@ -151,6 +170,9 @@ class WorkflowAgentRuntimeSessionStore:
row.composition_layer_specs = specs_json
row.status = WorkflowAgentRuntimeSessionStatus.ACTIVE
row.cleaned_at = None
# Set (or clear, when omitted) the ask_human pause correlation.
row.pending_form_id = pending_form_id
row.pending_tool_call_id = pending_tool_call_id
session.commit()
def mark_cleaned(self, *, scope: WorkflowAgentSessionScope, backend_run_id: str | None = None) -> None:

View File

@ -153,6 +153,7 @@ def init_app(app: DifyApp) -> Celery:
"tasks.trigger_processing_tasks", # async trigger processing
"tasks.generate_summary_index_task", # summary index generation
"tasks.regenerate_summary_index_task", # summary index regeneration
"tasks.app_generate.resume_agent_app_task", # ENG-635: Agent v2 chat ask_human resume
]
day = dify_config.CELERY_BEAT_SCHEDULER_TIME

View File

@ -5,6 +5,7 @@ def init_app(app: DifyApp):
from commands import (
add_qdrant_index,
archive_workflow_runs,
backfill_plugin_auto_upgrade,
clean_expired_messages,
clean_workflow_runs,
cleanup_orphaned_draft_variables,
@ -53,6 +54,7 @@ def init_app(app: DifyApp):
upgrade_db,
fix_app_site_missing,
migrate_data_for_plugin,
backfill_plugin_auto_upgrade,
extract_plugins,
extract_unique_plugins,
install_plugins,

View File

@ -44,7 +44,11 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel):
class AgentPublishedReferenceResponse(ResponseModel):
app_id: str
app_name: str
app_icon_type: str | None = None
app_icon: str | None = None
app_icon_background: str | None = None
app_mode: str
app_updated_at: int | None = None
workflow_id: str
workflow_version: str
node_ids: list[str] = Field(default_factory=list)
@ -66,6 +70,7 @@ class AgentRosterResponse(ResponseModel):
workflow_node_id: str | None = None
active_config_snapshot_id: str | None = None
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
active_config_is_published: bool = False
status: AgentStatus
created_by: str | None = None
updated_by: str | None = None

View File

@ -0,0 +1,32 @@
"""add ask_human pause correlation to agent_runtime_sessions
Revision ID: c167a72a00eb
Revises: c4d5e6f7a8b9
Create Date: 2026-06-15 10:52:15.736666
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c167a72a00eb'
down_revision = 'c4d5e6f7a8b9'
branch_labels = None
depends_on = None
def upgrade():
# ENG-637: correlate a paused dify.ask_human session to its awaiting HITL
# form and the deferred tool_call id, so the resumed Agent node can rebuild
# deferred_tool_results from the submitted form. Both columns are nullable
# and NULL whenever the session is not paused on human input.
with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op:
batch_op.add_column(sa.Column('pending_form_id', models.types.StringUUID(), nullable=True))
batch_op.add_column(sa.Column('pending_tool_call_id', sa.String(length=255), nullable=True))
def downgrade():
with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op:
batch_op.drop_column('pending_tool_call_id')
batch_op.drop_column('pending_form_id')

View File

@ -0,0 +1,26 @@
"""add tenant account join last opened at
Revision ID: b7c2d9e8a1f4
Revises: 9f4b7c2d1a80
Create Date: 2026-06-05 11:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b7c2d9e8a1f4"
down_revision = "9f4b7c2d1a80"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op:
batch_op.add_column(sa.Column("last_opened_at", sa.DateTime(), nullable=True))
def downgrade():
with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op:
batch_op.drop_column("last_opened_at")

View File

@ -0,0 +1,29 @@
"""add conversation_id to human_input_forms for agent v2 chat hitl
Revision ID: d2f1a4b8c3e0
Revises: c167a72a00eb
Create Date: 2026-06-15 11:10:00.000000
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd2f1a4b8c3e0'
down_revision = 'c167a72a00eb'
branch_labels = None
depends_on = None
def upgrade():
# ENG-635: Agent v2 chat ask_human forms are owned by a conversation turn
# instead of a workflow run (the new Agent App has no workflow_run_id).
# Nullable; existing workflow-owned forms keep conversation_id NULL.
with op.batch_alter_table('human_input_forms', schema=None) as batch_op:
batch_op.add_column(sa.Column('conversation_id', models.types.StringUUID(), nullable=True))
def downgrade():
with op.batch_alter_table('human_input_forms', schema=None) as batch_op:
batch_op.drop_column('conversation_id')

View File

@ -0,0 +1,42 @@
"""add plugin auto upgrade category
Revision ID: f6a7b8c9d012
Revises: b7c2d9e8a1f4
Create Date: 2026-05-15 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f6a7b8c9d012"
down_revision = "b7c2d9e8a1f4"
branch_labels = None
depends_on = None
LEGACY_CATEGORY = "tool"
UNIQUE_CONSTRAINT_NAME = "unique_tenant_plugin_auto_upgrade_strategy"
UPGRADE_TIME_INDEX_NAME = "idx_tenant_plugin_auto_upgrade_strategy_time"
STRATEGY_TABLE_NAME = "tenant_plugin_auto_upgrade_strategies"
def upgrade():
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
batch_op.add_column(
sa.Column("category", sa.String(length=32), server_default=LEGACY_CATEGORY, nullable=False)
)
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id", "category"])
batch_op.create_index(UPGRADE_TIME_INDEX_NAME, ["upgrade_time_of_day"])
def downgrade():
op.execute(sa.text(f"DELETE FROM {STRATEGY_TABLE_NAME} WHERE category != '{LEGACY_CATEGORY}'"))
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
batch_op.drop_index(UPGRADE_TIME_INDEX_NAME)
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
batch_op.drop_column("category")
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id"])

View File

@ -0,0 +1,26 @@
"""add learn dify flag to recommended apps
Revision ID: f5e8a9c0d2b3
Revises: f6a7b8c9d012
Create Date: 2026-05-18 15:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f5e8a9c0d2b3"
down_revision = "f6a7b8c9d012"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
batch_op.add_column(sa.Column("is_learn_dify", sa.Boolean(), server_default=sa.text("false"), nullable=False))
def downgrade():
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
batch_op.drop_column("is_learn_dify")

View File

@ -0,0 +1,38 @@
"""add app stars
Revision ID: c4d5e6f7a8b9
Revises: f5e8a9c0d2b3
Create Date: 2026-06-08 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models.types
# revision identifiers, used by Alembic.
revision = "c4d5e6f7a8b9"
down_revision = "f5e8a9c0d2b3"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"app_stars",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name="app_star_pkey"),
sa.UniqueConstraint("tenant_id", "account_id", "app_id", name="app_star_tenant_account_app_unique"),
)
with op.batch_alter_table("app_stars", schema=None) as batch_op:
batch_op.create_index("app_star_tenant_account_idx", ["tenant_id", "account_id"], unique=False)
batch_op.create_index("app_star_app_idx", ["app_id"], unique=False)
def downgrade() -> None:
op.drop_table("app_stars")

View File

@ -73,6 +73,7 @@ from .model import (
AppMCPServer,
AppMode,
AppModelConfig,
AppStar,
Conversation,
DatasetRetrieverResource,
DifySetup,
@ -175,6 +176,7 @@ __all__ = [
"AppMCPServer",
"AppMode",
"AppModelConfig",
"AppStar",
"AppTrigger",
"AppTriggerStatus",
"AppTriggerType",

View File

@ -301,6 +301,7 @@ class TenantAccountJoin(TypeBase):
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.current_timestamp(), nullable=False, init=False, onupdate=func.current_timestamp()
)
last_opened_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
class AccountIntegrate(TypeBase):
@ -389,6 +390,14 @@ class TenantPluginPermission(TypeBase):
class TenantPluginAutoUpgradeStrategy(TypeBase):
class PluginCategory(enum.StrEnum):
TOOL = "tool"
MODEL = "model"
EXTENSION = "extension"
AGENT_STRATEGY = "agent-strategy"
DATASOURCE = "datasource"
TRIGGER = "trigger"
class StrategySetting(enum.StrEnum):
DISABLED = "disabled"
FIX_ONLY = "fix_only"
@ -402,13 +411,20 @@ class TenantPluginAutoUpgradeStrategy(TypeBase):
__tablename__ = "tenant_plugin_auto_upgrade_strategies"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="tenant_plugin_auto_upgrade_strategy_pkey"),
sa.UniqueConstraint("tenant_id", name="unique_tenant_plugin_auto_upgrade_strategy"),
sa.UniqueConstraint("tenant_id", "category", name="unique_tenant_plugin_auto_upgrade_strategy"),
sa.Index("idx_tenant_plugin_auto_upgrade_strategy_time", "upgrade_time_of_day"),
)
id: Mapped[str] = mapped_column(
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
category: Mapped[PluginCategory] = mapped_column(
EnumText(PluginCategory, length=32),
nullable=False,
server_default="tool",
default=PluginCategory.TOOL,
)
strategy_setting: Mapped[StrategySetting] = mapped_column(
EnumText(StrategySetting, length=16),
nullable=False,

View File

@ -398,6 +398,12 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base):
default=AgentRuntimeSessionStatus.ACTIVE,
)
cleaned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# ENG-637: when a run pauses for a dify.ask_human deferred call, these link
# the session to the awaiting HITL form and the deferred tool_call_id, so a
# resumed node can map the submitted form back into deferred_tool_results.
# Both NULL whenever the session is not paused on human input.
pending_form_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
pending_tool_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Back-compat alias for the shipped workflow lifecycle code (PR #36724).

View File

@ -2,9 +2,9 @@ from __future__ import annotations
import re
from enum import StrEnum
from typing import Any, Final, Literal
from typing import Annotated, Any, Final, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator
from core.workflow.file_reference import is_canonical_file_reference
from graphon.file import FileTransferMethod
@ -29,6 +29,44 @@ class DeclaredOutputType(StrEnum):
FILE = "file"
_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA = {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"type": {
"type": "string",
"enum": [item.value for item in DeclaredOutputType],
},
"description": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"required": {"type": "boolean"},
"file": {"type": "object", "additionalProperties": True},
"array_item": {
"type": "object",
"additionalProperties": True,
"properties": {
"type": {
"type": "string",
"enum": [item.value for item in DeclaredOutputType],
},
"description": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"children": {"type": "array", "items": {"type": "object", "additionalProperties": True}},
},
},
"children": {"type": "array", "items": {"type": "object", "additionalProperties": True}},
},
"required": ["name", "type"],
},
}
DeclaredOutputChildren = Annotated[
list["DeclaredOutputChildConfig"],
WithJsonSchema(_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA),
]
class AgentCliToolAuthorizationStatus(StrEnum):
"""Authorization state for Agent-scoped CLI tools.
@ -148,6 +186,9 @@ class AgentSecretRefConfig(AgentFlexibleConfig):
env_name: str | None = Field(default=None, max_length=255)
variable: str | None = Field(default=None, max_length=255)
type: str | None = Field(default=None, max_length=64)
# UI-facing selected secret reference. This is a credential/ref id, not the
# plaintext secret value; runtime maps it to the shell-layer ``ref``.
value: str | None = Field(default=None, max_length=255)
id: str | None = Field(default=None, max_length=255)
ref: str | None = Field(default=None, max_length=255)
credential_id: str | None = Field(default=None, max_length=255)
@ -507,11 +548,55 @@ class DeclaredArrayItem(BaseModel):
type: DeclaredOutputType
description: str | None = None
children: DeclaredOutputChildren = Field(default_factory=list)
@model_validator(mode="after")
def _reject_nested_array(self) -> DeclaredArrayItem:
if self.type == DeclaredOutputType.ARRAY:
raise ValueError("nested arrays are not supported as array_item.type")
if self.children and self.type != DeclaredOutputType.OBJECT:
raise ValueError("array_item.children is only allowed when array_item.type is object")
return self
class DeclaredOutputChildConfig(BaseModel):
"""Nested field under an object-shaped declared output.
The first backend version keeps child fields lightweight: they describe the
variable-picker/schema tree but do not own independent retry/check behavior.
"""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
type: DeclaredOutputType
description: str | None = None
required: bool = True
file: DeclaredOutputFileConfig | None = None
array_item: DeclaredArrayItem | None = None
children: DeclaredOutputChildren = Field(default_factory=list)
@model_validator(mode="after")
def _validate_shape(self) -> DeclaredOutputChildConfig:
if not _OUTPUT_NAME_PATTERN.fullmatch(self.name):
raise ValueError(
f"output child name {self.name!r} must match {_OUTPUT_NAME_PATTERN.pattern} "
"(JSON-schema-friendly identifier)"
)
if self.type == DeclaredOutputType.FILE:
if self.file is None:
self.file = DeclaredOutputFileConfig()
elif self.file is not None:
raise ValueError("file metadata is only allowed for file output children")
if self.type == DeclaredOutputType.ARRAY:
if self.array_item is None:
self.array_item = DeclaredArrayItem(type=DeclaredOutputType.OBJECT)
elif self.array_item is not None:
raise ValueError("array_item is only allowed when child type is array")
if self.children and self.type != DeclaredOutputType.OBJECT:
raise ValueError("children is only allowed for object output children")
return self
@ -592,6 +677,7 @@ class DeclaredOutputConfig(BaseModel):
required: bool = True
file: DeclaredOutputFileConfig | None = None
array_item: DeclaredArrayItem | None = None
children: DeclaredOutputChildren = Field(default_factory=list)
check: DeclaredOutputCheckConfig | None = None
failure_strategy: DeclaredOutputFailureStrategy = Field(default_factory=DeclaredOutputFailureStrategy)
@ -625,6 +711,9 @@ class DeclaredOutputConfig(BaseModel):
elif self.array_item is not None:
raise ValueError("array_item is only allowed when type is array")
if self.children and self.type != DeclaredOutputType.OBJECT:
raise ValueError("children is only allowed for object outputs")
# Per PRD §OUTPUT 配置框: output check is file-only.
if self.check is not None and self.check.enabled and self.type != DeclaredOutputType.FILE:
raise ValueError("output check is only allowed for file outputs")

View File

@ -40,6 +40,13 @@ class HumanInputForm(DefaultFieldsMixin, Base):
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
# ENG-635: a RUNTIME form is tagged with its owning workflow run and/or its
# conversation. Workflow / Human-Input / agent-node forms always set
# workflow_run_id, and ALSO set conversation_id when the run has a conversation
# (chatflow / advanced-chat). Agent v2 chat ask_human forms set only
# conversation_id (the new Agent App has no workflow_run_id). At least one is set;
# resume routing prefers workflow_run_id when both are present.
conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
form_kind: Mapped[HumanInputFormKind] = mapped_column(
EnumText(HumanInputFormKind),
nullable=False,

View File

@ -397,6 +397,12 @@ class App(Base):
__tablename__ = "apps"
__table_args__ = (sa.PrimaryKeyConstraint("id", name="app_pkey"), sa.Index("app_tenant_id_idx", "tenant_id"))
if TYPE_CHECKING:
# Response-only attributes attached by app list/detail enrichers.
access_mode: str | None
has_draft_trigger: bool
is_starred: bool
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
tenant_id: Mapped[str] = mapped_column(StringUUID)
name: Mapped[str] = mapped_column(String(255))
@ -654,6 +660,28 @@ class App(Base):
return None
class AppStar(Base):
"""Account-scoped star marker for apps in a workspace."""
__tablename__ = "app_stars"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="app_star_pkey"),
sa.UniqueConstraint("tenant_id", "account_id", "app_id", name="app_star_tenant_account_app_unique"),
sa.Index("app_star_tenant_account_idx", "tenant_id", "account_id"),
sa.Index("app_star_app_idx", "app_id"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
@override
def __repr__(self) -> str:
return f"<AppStar app_id={self.app_id} account_id={self.account_id}>"
class AppModelConfig(TypeBase):
__tablename__ = "app_model_configs"
__table_args__ = (sa.PrimaryKeyConstraint("id", name="app_model_config_pkey"), sa.Index("app_app_id_idx", "app_id"))
@ -907,6 +935,9 @@ class RecommendedApp(TypeBase):
custom_disclaimer: Mapped[str] = mapped_column(LongText, default="")
position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
is_learn_dify: Mapped[bool] = mapped_column(
sa.Boolean, nullable=False, server_default=sa.text("false"), default=False
)
install_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
language: Mapped[str] = mapped_column(
String(255),

File diff suppressed because it is too large Load Diff

View File

@ -73,6 +73,7 @@ def check_upgradable_plugin_task():
strategy.upgrade_mode,
strategy.exclude_plugins,
strategy.include_plugins,
strategy.category,
)
# Only sleep if batch_interval_time > 0.0001 AND current batch is not the last one

View File

@ -70,6 +70,7 @@ from services.errors.account import (
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
from tasks.mail_change_mail_task import (
@ -263,6 +264,7 @@ class AccountService:
account.set_tenant_id(available_ta.tenant_id)
available_ta.current = True
available_ta.last_opened_at = naive_utc_now()
db.session.commit()
AccountService._refresh_account_last_active(account)
@ -1167,15 +1169,17 @@ class TenantService:
db.session.add(tenant)
db.session.commit()
plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant.id,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
db.session.add(plugin_upgrade_strategy)
for category in TenantPluginAutoUpgradeStrategy.PluginCategory:
plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant.id,
category=category,
strategy_setting=PluginAutoUpgradeService.default_strategy_setting_for_category(category),
upgrade_time_of_day=PluginAutoUpgradeService.default_upgrade_time_of_day(tenant.id),
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
db.session.add(plugin_upgrade_strategy)
db.session.commit()
tenant.encrypt_public_key = generate_key_pair(tenant.id)
@ -1447,6 +1451,7 @@ class TenantService:
.values(current=False)
)
tenant_account_join.current = True
tenant_account_join.last_opened_at = naive_utc_now()
# Set the current tenant for the account
account.set_tenant_id(tenant_account_join.tenant_id)
db.session.commit()

View File

@ -1,6 +1,6 @@
from typing import Any, TypedDict
from sqlalchemy import func, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from libs.datetime_utils import naive_utc_now
@ -19,7 +19,7 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import AppStatus
from models.model import App
from models.model import App, AppMode
from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
@ -37,7 +37,11 @@ class AgentReferencingWorkflow(TypedDict):
app_id: str
app_name: str
app_icon_type: str | None
app_icon: str | None
app_icon_background: str | None
app_mode: str
app_updated_at: int | None
workflow_id: str
workflow_version: str
node_ids: list[str]
@ -52,6 +56,7 @@ class AgentRosterService:
agent: Agent,
active_version: AgentConfigSnapshot | None = None,
published_references: list[AgentReferencingWorkflow] | None = None,
active_config_is_published: bool = False,
) -> dict[str, Any]:
published_references = published_references or []
return {
@ -70,6 +75,7 @@ class AgentRosterService:
"workflow_node_id": agent.workflow_node_id,
"active_config_snapshot_id": agent.active_config_snapshot_id,
"active_config_snapshot": AgentRosterService.serialize_version(active_version) if active_version else None,
"active_config_is_published": active_config_is_published,
"status": agent.status.value,
"created_by": agent.created_by,
"updated_by": agent.updated_by,
@ -124,6 +130,10 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents],
)
active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=agents,
)
data = []
for agent in agents:
@ -135,6 +145,7 @@ class AgentRosterService:
agent,
active_version,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
)
)
@ -161,11 +172,16 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents],
)
active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=agents,
)
data = [
self.serialize_agent(
agent,
versions_by_id.get(agent.active_config_snapshot_id) if agent.active_config_snapshot_id else None,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
)
for agent in agents
]
@ -278,6 +294,7 @@ class AgentRosterService:
app_id: str,
name: str,
description: str = "",
role: str = "",
icon_type: Any = None,
icon: str | None = None,
icon_background: str | None = None,
@ -294,7 +311,7 @@ class AgentRosterService:
tenant_id=tenant_id,
name=name,
description=description,
role="",
role=role,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
@ -337,6 +354,21 @@ class AgentRosterService:
self._session.flush()
return agent
def load_app_backing_agents_by_app_id(self, *, tenant_id: str, app_ids: list[str]) -> dict[str, Agent]:
"""Return active app-backed Agents keyed by Agent App id."""
if not app_ids:
return {}
agents = self._session.scalars(
select(Agent).where(
Agent.tenant_id == tenant_id,
Agent.app_id.in_(app_ids),
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
).all()
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."""
return self._session.scalar(
@ -349,6 +381,43 @@ class AgentRosterService:
)
)
def get_agent_app_model(self, *, tenant_id: str, agent_id: str) -> App:
"""Resolve the Agent App hidden behind an app-backed Agent id.
The public /agent route uses Agent ids, while the runtime and legacy app
APIs still operate on App ids internally. Only app-backed roster Agents
are accepted here; workflow-only Agents and historical standalone roster
Agents are not Agent App resources.
"""
agent = self._session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.id == agent_id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.app_id.is_not(None),
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None or agent.app_id is None:
raise AgentNotFoundError()
app = self._session.scalar(
select(App)
.where(
App.tenant_id == tenant_id,
App.id == agent.app_id,
App.mode == AppMode.AGENT,
App.status == AppStatus.NORMAL,
)
.limit(1)
)
if app is None:
raise AgentNotFoundError()
return app
def list_workflows_referencing_app_agent(self, *, tenant_id: str, app_id: str) -> list[AgentReferencingWorkflow]:
"""List the workflow apps that reference this Agent App's bound Agent.
@ -372,7 +441,16 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_ids=[agent.id],
)
return self.serialize_agent(agent, active_version, published_references_by_agent_id.get(agent.id, []))
active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=[agent],
)
return self.serialize_agent(
agent,
active_version,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
)
def update_roster_agent(
self, *, tenant_id: str, agent_id: str, account_id: str, payload: RosterAgentUpdatePayload
@ -403,12 +481,48 @@ class AgentRosterService:
agent.updated_by = account_id
self._session.commit()
@staticmethod
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
if agent.source == AgentSource.AGENT_APP:
return {AgentConfigRevisionOperation.SAVE_NEW_VERSION}
return {
AgentConfigRevisionOperation.CREATE_VERSION,
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.SAVE_NEW_AGENT,
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
}
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
"""Return whether the Agent's current active snapshot is a visible published version."""
return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get(
agent.id,
False,
)
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
"""Return publish-state flags for the active config snapshots of the given Agents."""
published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents)
return {agent.id: agent.id in published_agent_ids for agent in agents}
def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
visible_version_ids = (
select(AgentConfigRevision.current_snapshot_id)
.where(
AgentConfigRevision.tenant_id == tenant_id,
AgentConfigRevision.agent_id == agent_id,
AgentConfigRevision.operation.in_(self._visible_version_operations(agent)),
)
.subquery()
)
versions = list(
self._session.scalars(
select(AgentConfigSnapshot)
.where(AgentConfigSnapshot.tenant_id == tenant_id, AgentConfigSnapshot.agent_id == agent_id)
.where(
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent_id,
AgentConfigSnapshot.id.in_(select(visible_version_ids.c.current_snapshot_id)),
)
.order_by(AgentConfigSnapshot.version.desc())
).all()
)
@ -419,7 +533,19 @@ class AgentRosterService:
]
def get_agent_version_detail(self, *, tenant_id: str, agent_id: str, version_id: str) -> dict[str, Any]:
self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
visible_revision_id = self._session.scalar(
select(AgentConfigRevision.id)
.where(
AgentConfigRevision.tenant_id == tenant_id,
AgentConfigRevision.agent_id == agent_id,
AgentConfigRevision.current_snapshot_id == version_id,
AgentConfigRevision.operation.in_(self._visible_version_operations(agent)),
)
.limit(1)
)
if not visible_revision_id:
raise AgentVersionNotFoundError()
version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
revisions = list(
self._session.scalars(
@ -475,6 +601,29 @@ class AgentRosterService:
raise AgentVersionNotFoundError()
return version
def _load_published_active_snapshot_agent_ids(self, *, tenant_id: str, agents: list[Agent]) -> set[str]:
predicates = [
and_(
AgentConfigRevision.agent_id == agent.id,
AgentConfigRevision.current_snapshot_id == agent.active_config_snapshot_id,
AgentConfigRevision.operation.in_(self._visible_version_operations(agent)),
)
for agent in agents
if agent.active_config_snapshot_id
]
if not predicates:
return set()
agent_ids = self._session.scalars(
select(AgentConfigRevision.agent_id)
.where(
AgentConfigRevision.tenant_id == tenant_id,
or_(*predicates),
)
.distinct()
).all()
return set(agent_ids)
def _load_published_references_by_agent_id(
self, *, tenant_id: str, agent_ids: list[str]
) -> dict[str, list[AgentReferencingWorkflow]]:
@ -520,7 +669,11 @@ class AgentRosterService:
AgentReferencingWorkflow(
app_id=binding.app_id,
app_name=app.name,
app_icon_type=(icon_type.value if (icon_type := getattr(app, "icon_type", None)) else None),
app_icon=getattr(app, "icon", None),
app_icon_background=getattr(app, "icon_background", None),
app_mode=str(app.mode),
app_updated_at=to_timestamp(getattr(app, "updated_at", None)),
workflow_id=binding.workflow_id,
workflow_version=binding.workflow_version,
node_ids=[],
@ -533,7 +686,7 @@ class AgentRosterService:
references = list(by_workflow.values())
for reference in references:
reference["node_ids"] = sorted(set(reference["node_ids"]))
references.sort(key=lambda item: (item["app_name"].lower(), item["workflow_id"]))
references.sort(key=lambda item: (-(item["app_updated_at"] or 0), item["app_name"].lower()))
result[agent_id] = references
return result

View File

@ -1,14 +1,23 @@
from __future__ import annotations
import copy
from collections.abc import Mapping
from typing import Any
from typing import Any, cast
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from models.agent import Agent, AgentScope, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent_config_entities import WorkflowNodeJobConfig
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentScope,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import DeclaredOutputConfig, WorkflowNodeJobConfig
from models.workflow import Workflow
@ -17,6 +26,43 @@ class WorkflowAgentPublishService:
_DRAFT_WORKFLOW_VERSION = Workflow.VERSION_DRAFT
_AGENT_BINDING_KEY = "agent_binding"
_AGENT_TASK_KEY = "agent_task"
_AGENT_DECLARED_OUTPUTS_KEY = "agent_declared_outputs"
@classmethod
def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]:
"""Return draft graph with persisted Agent node job config projected into node data.
Workflow draft graph is the front-end's editing source of truth, while
runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This
response-only projection keeps reads aligned without writing binding
details back into the stored graph JSON.
"""
graph = cast(dict[str, Any], copy.deepcopy(draft_workflow.graph_dict))
agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph))
if not agent_nodes:
return graph
bindings = session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id,
WorkflowAgentNodeBinding.app_id == draft_workflow.app_id,
WorkflowAgentNodeBinding.workflow_id == draft_workflow.id,
WorkflowAgentNodeBinding.workflow_version == cls._DRAFT_WORKFLOW_VERSION,
WorkflowAgentNodeBinding.node_id.in_(list(agent_nodes.keys())),
)
).all()
for binding in bindings:
node_data = agent_nodes.get(binding.node_id)
if not isinstance(node_data, dict):
continue
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
node_data[cls._AGENT_DECLARED_OUTPUTS_KEY] = [
output.model_dump(mode="json") for output in node_job.declared_outputs
]
return graph
@classmethod
def validate_agent_nodes_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None:
@ -27,7 +73,7 @@ class WorkflowAgentPublishService:
WorkflowAgentNodeValidator.validate_draft_workflow(session=session, workflow=draft_workflow)
@classmethod
def sync_roster_agent_bindings_for_draft(
def sync_agent_bindings_for_draft(
cls,
*,
session: Session,
@ -57,10 +103,11 @@ class WorkflowAgentPublishService:
continue
if not isinstance(binding_payload, Mapping):
raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.")
cls._sync_roster_agent_binding_for_node(
cls._sync_agent_binding_for_node(
session=session,
draft_workflow=draft_workflow,
node_id=node_id,
node_data=node_data,
node_binding=binding_payload,
existing_binding=existing_by_node_id.get(node_id),
account_id=account_id,
@ -68,23 +115,93 @@ class WorkflowAgentPublishService:
session.flush()
@classmethod
def _sync_roster_agent_binding_for_node(
def sync_roster_agent_bindings_for_draft(
cls,
*,
session: Session,
draft_workflow: Workflow,
account_id: str,
) -> None:
cls.sync_agent_bindings_for_draft(
session=session,
draft_workflow=draft_workflow,
account_id=account_id,
)
@classmethod
def _sync_agent_binding_for_node(
cls,
*,
session: Session,
draft_workflow: Workflow,
node_id: str,
node_data: Mapping[str, Any],
node_binding: Mapping[str, Any],
existing_binding: WorkflowAgentNodeBinding | None,
account_id: str,
) -> None:
binding_type = node_binding.get("binding_type")
if binding_type != WorkflowAgentBindingType.ROSTER_AGENT.value:
raise ValueError(f"Workflow Agent node {node_id} only supports roster_agent graph binding.")
agent_id = node_binding.get("agent_id")
if not isinstance(agent_id, str) or not agent_id:
raise ValueError(f"Workflow Agent node {node_id} roster_agent binding requires agent_id.")
raise ValueError(f"Workflow Agent node {node_id} agent binding requires agent_id.")
if binding_type == WorkflowAgentBindingType.ROSTER_AGENT.value:
agent, current_snapshot_id = cls._resolve_roster_agent_graph_binding(
session=session,
draft_workflow=draft_workflow,
node_id=node_id,
agent_id=agent_id,
)
resolved_binding_type = WorkflowAgentBindingType.ROSTER_AGENT
elif binding_type == WorkflowAgentBindingType.INLINE_AGENT.value:
raw_current_snapshot_id = node_binding.get("current_snapshot_id")
if not isinstance(raw_current_snapshot_id, str) or not raw_current_snapshot_id:
raise ValueError(f"Workflow Agent node {node_id} inline_agent binding requires current_snapshot_id.")
current_snapshot_id = raw_current_snapshot_id
agent = cls._resolve_inline_agent_graph_binding(
session=session,
draft_workflow=draft_workflow,
node_id=node_id,
agent_id=agent_id,
current_snapshot_id=current_snapshot_id,
)
resolved_binding_type = WorkflowAgentBindingType.INLINE_AGENT
else:
raise ValueError(f"Workflow Agent node {node_id} has unsupported agent_binding type.")
binding = existing_binding
node_job_config = cls._node_job_config_from_node_data(
existing_binding=existing_binding,
node_data=node_data,
)
if binding is None:
binding = WorkflowAgentNodeBinding(
tenant_id=draft_workflow.tenant_id,
app_id=draft_workflow.app_id,
workflow_id=draft_workflow.id,
workflow_version=cls._DRAFT_WORKFLOW_VERSION,
node_id=node_id,
node_job_config=node_job_config,
created_by=account_id,
)
session.add(binding)
else:
binding.node_job_config = node_job_config
binding.binding_type = resolved_binding_type
binding.agent_id = agent.id
binding.current_snapshot_id = current_snapshot_id
binding.updated_by = account_id
@classmethod
def _resolve_roster_agent_graph_binding(
cls,
*,
session: Session,
draft_workflow: Workflow,
node_id: str,
agent_id: str,
) -> tuple[Agent, str]:
agent = session.scalar(
select(Agent)
.where(
@ -97,28 +214,86 @@ class WorkflowAgentPublishService:
)
if agent is None:
raise ValueError(f"Workflow Agent node {node_id} references an unavailable roster agent.")
if agent.scope != AgentScope.ROSTER:
raise ValueError(f"Workflow Agent node {node_id} roster_agent binding must reference a roster agent.")
if not agent.active_config_snapshot_id:
raise ValueError(f"Workflow Agent node {node_id} roster agent has no active config snapshot.")
return agent, agent.active_config_snapshot_id
binding = existing_binding
if binding is None:
binding = WorkflowAgentNodeBinding(
tenant_id=draft_workflow.tenant_id,
app_id=draft_workflow.app_id,
workflow_id=draft_workflow.id,
workflow_version=cls._DRAFT_WORKFLOW_VERSION,
node_id=node_id,
node_job_config=WorkflowNodeJobConfig(),
created_by=account_id,
@classmethod
def _resolve_inline_agent_graph_binding(
cls,
*,
session: Session,
draft_workflow: Workflow,
node_id: str,
agent_id: str,
current_snapshot_id: str,
) -> Agent:
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == draft_workflow.tenant_id,
Agent.id == agent_id,
Agent.scope == AgentScope.WORKFLOW_ONLY,
Agent.app_id == draft_workflow.app_id,
Agent.workflow_id == draft_workflow.id,
Agent.workflow_node_id == node_id,
Agent.status == AgentStatus.ACTIVE,
)
session.add(binding)
elif not binding.node_job_config:
binding.node_job_config = WorkflowNodeJobConfig()
.limit(1)
)
if agent is None:
raise ValueError(f"Workflow Agent node {node_id} references an unavailable inline agent.")
if (
agent.scope != AgentScope.WORKFLOW_ONLY
or agent.app_id != draft_workflow.app_id
or agent.workflow_id != draft_workflow.id
or agent.workflow_node_id != node_id
):
raise ValueError(f"Workflow Agent node {node_id} inline_agent binding does not belong to this node.")
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
binding.agent_id = agent.id
binding.current_snapshot_id = agent.active_config_snapshot_id
binding.updated_by = account_id
snapshot = session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == draft_workflow.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == current_snapshot_id,
)
.limit(1)
)
if snapshot is None or snapshot.agent_id != agent.id:
raise ValueError(f"Workflow Agent node {node_id} references a missing inline agent config snapshot.")
return agent
@classmethod
def _node_job_config_from_node_data(
cls,
*,
existing_binding: WorkflowAgentNodeBinding | None,
node_data: Mapping[str, Any],
) -> WorkflowNodeJobConfig:
if existing_binding and existing_binding.node_job_config:
node_job = WorkflowNodeJobConfig.model_validate(existing_binding.node_job_config_dict)
else:
node_job = WorkflowNodeJobConfig()
agent_task = node_data.get(cls._AGENT_TASK_KEY)
if isinstance(agent_task, str):
node_job.workflow_prompt = agent_task
declared_outputs_payload = node_data.get(cls._AGENT_DECLARED_OUTPUTS_KEY)
if declared_outputs_payload is not None:
if not isinstance(declared_outputs_payload, list):
raise ValueError("Workflow Agent node agent_declared_outputs must be a list.")
try:
node_job.declared_outputs = [
DeclaredOutputConfig.model_validate(output) for output in declared_outputs_payload
]
except ValidationError as exc:
raise ValueError("Workflow Agent node has invalid agent_declared_outputs.") from exc
return node_job
@classmethod
def copy_agent_node_bindings_to_published(

View File

@ -2,7 +2,7 @@ import json
import logging
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Literal, TypedDict, cast, override
from typing import Any, Literal, NotRequired, TypedDict, cast, override
import sqlalchemy as sa
from flask_sqlalchemy.pagination import Pagination
@ -23,7 +23,7 @@ from graphon.model_runtime.entities.model_entities import ModelPropertyKey, Mode
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
from libs.datetime_utils import naive_utc_now
from libs.login import current_user
from models import Account
from models import Account, AppStar
from models.agent import Agent, AgentIconType, AgentScope, AgentSource, AgentStatus
from models.model import App, AppMode, AppModelConfig, IconType, Site
from models.tools import ApiToolProvider
@ -36,23 +36,34 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t
logger = logging.getLogger(__name__)
AppListSortBy = Literal["last_modified", "recently_created", "earliest_created"]
class AppListParams(BaseModel):
class AppListBaseParams(BaseModel):
page: int = Field(default=1, ge=1)
limit: int = Field(default=20, ge=1, le=100)
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = "all"
sort_by: AppListSortBy = "last_modified"
name: str | None = None
tag_ids: list[str] | None = None
creator_ids: list[str] | None = None
is_created_by_me: bool | None = None
class AppListParams(AppListBaseParams):
status: str | None = None
openapi_visible: bool = False
class StarredAppListParams(AppListBaseParams):
pass
class CreateAppParams(BaseModel):
name: str = Field(min_length=1)
description: str | None = None
mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
agent_role: str = Field(default="", max_length=255)
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
@ -62,6 +73,85 @@ class CreateAppParams(BaseModel):
class AppService:
@staticmethod
def _build_app_list_filters(
user_id: str, tenant_id: str, params: AppListBaseParams
) -> list[sa.ColumnElement[bool]]:
filters = [App.tenant_id == tenant_id, App.is_universal == False]
if params.mode == "workflow":
filters.append(App.mode == AppMode.WORKFLOW)
elif params.mode == "completion":
filters.append(App.mode == AppMode.COMPLETION)
elif params.mode == "chat":
filters.append(App.mode == AppMode.CHAT)
elif params.mode == "advanced-chat":
filters.append(App.mode == AppMode.ADVANCED_CHAT)
elif params.mode == "agent-chat":
filters.append(App.mode == AppMode.AGENT_CHAT)
elif params.mode == "agent":
filters.append(App.mode == AppMode.AGENT)
elif params.mode == "all":
filters.append(App.mode != AppMode.AGENT)
if isinstance(params, AppListParams):
if params.status:
filters.append(App.status == params.status)
# OpenAPI surface visibility gate. Pushed into the query so
# `pagination.total` reflects only apps the openapi caller can
# actually reach; post-filtering by enable_api after the page
# arrives would make `total` page-dependent.
if params.openapi_visible:
filters.append(App.enable_api.is_(True))
if params.is_created_by_me:
filters.append(App.created_by == user_id)
if params.creator_ids:
filters.append(App.created_by.in_(params.creator_ids))
if params.name:
from libs.helper import escape_like_pattern
name = params.name[:30]
escaped_name = escape_like_pattern(name)
filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\"))
if params.tag_ids and len(params.tag_ids) > 0:
target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids, match_all=True)
if target_ids and len(target_ids) > 0:
filters.append(App.id.in_(target_ids))
else:
return []
return filters
@staticmethod
def _build_app_list_order_by(sort_by: AppListSortBy) -> sa.ColumnElement[Any]:
return {
"last_modified": App.updated_at.desc(),
"recently_created": App.created_at.desc(),
"earliest_created": App.created_at.asc(),
}[sort_by]
@staticmethod
def get_starred_app_ids(
session: Session | scoped_session,
*,
tenant_id: str,
account_id: str,
app_ids: Sequence[str],
) -> set[str]:
"""Return app IDs starred by this account within the tenant."""
if not app_ids:
return set()
starred_app_ids = session.scalars(
select(AppStar.app_id).where(
AppStar.tenant_id == tenant_id,
AppStar.account_id == account_id,
AppStar.app_id.in_(list(app_ids)),
)
).all()
return set(starred_app_ids)
@staticmethod
def get_app_by_id(
session: Session | scoped_session,
@ -109,61 +199,104 @@ class AppService:
def get_paginate_apps(self, user_id: str, tenant_id: str, params: AppListParams) -> Pagination | None:
"""
Get app list with pagination
Get app list with pagination, filters, and explicit sort order.
:param user_id: user id
:param tenant_id: tenant id
:param params: query parameters
:return:
"""
filters = [App.tenant_id == tenant_id, App.is_universal == False]
filters = self._build_app_list_filters(user_id, tenant_id, params)
if not filters:
return None
if params.mode == "workflow":
filters.append(App.mode == AppMode.WORKFLOW)
elif params.mode == "completion":
filters.append(App.mode == AppMode.COMPLETION)
elif params.mode == "chat":
filters.append(App.mode == AppMode.CHAT)
elif params.mode == "advanced-chat":
filters.append(App.mode == AppMode.ADVANCED_CHAT)
elif params.mode == "agent-chat":
filters.append(App.mode == AppMode.AGENT_CHAT)
elif params.mode == "agent":
filters.append(App.mode == AppMode.AGENT)
if params.status:
filters.append(App.status == params.status)
# OpenAPI surface visibility gate. Pushed into the query so
# `pagination.total` reflects only apps the openapi caller can
# actually reach — post-filtering by enable_api after the page
# arrives would make `total` page-dependent.
if params.openapi_visible:
filters.append(App.enable_api.is_(True))
if params.is_created_by_me:
filters.append(App.created_by == user_id)
if params.creator_ids:
filters.append(App.created_by.in_(params.creator_ids))
if params.name:
from libs.helper import escape_like_pattern
name = params.name[:30]
escaped_name = escape_like_pattern(name)
filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\"))
if params.tag_ids and len(params.tag_ids) > 0:
target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids, match_all=True)
if target_ids and len(target_ids) > 0:
filters.append(App.id.in_(target_ids))
else:
return None
order_by = self._build_app_list_order_by(params.sort_by)
app_models = db.paginate(
sa.select(App).where(*filters).order_by(App.created_at.desc()),
sa.select(App).where(*filters).order_by(order_by),
page=params.page,
per_page=params.limit,
error_out=False,
)
app_ids = [str(app.id) for app in app_models.items]
starred_app_ids = self.get_starred_app_ids(
db.session,
tenant_id=tenant_id,
account_id=user_id,
app_ids=app_ids,
)
for app in app_models.items:
app.is_starred = str(app.id) in starred_app_ids
return app_models
def get_paginate_starred_apps(
self, user_id: str, tenant_id: str, params: StarredAppListParams
) -> Pagination | None:
"""
Get apps starred by the current account with pagination, filters, and explicit sort order.
"""
filters = self._build_app_list_filters(user_id, tenant_id, params)
if not filters:
return None
order_by = self._build_app_list_order_by(params.sort_by)
app_models = db.paginate(
sa.select(App)
.join(
AppStar,
sa.and_(
AppStar.tenant_id == App.tenant_id,
AppStar.app_id == App.id,
AppStar.account_id == user_id,
),
)
.where(AppStar.tenant_id == tenant_id, *filters)
.order_by(order_by),
page=params.page,
per_page=params.limit,
error_out=False,
)
for app in app_models.items:
app.is_starred = True
return app_models
@staticmethod
def star_app(session: Session, *, app: App, account_id: str) -> None:
"""Create the account's app star if it does not already exist."""
existing_star = session.scalar(
select(AppStar)
.where(
AppStar.tenant_id == app.tenant_id,
AppStar.app_id == app.id,
AppStar.account_id == account_id,
)
.limit(1)
)
if existing_star:
return
session.add(AppStar(tenant_id=app.tenant_id, app_id=app.id, account_id=account_id))
@staticmethod
def unstar_app(session: Session, *, app: App, account_id: str) -> None:
"""Remove the account's app star if present."""
existing_star = session.scalar(
select(AppStar)
.where(
AppStar.tenant_id == app.tenant_id,
AppStar.app_id == app.id,
AppStar.account_id == account_id,
)
.limit(1)
)
if not existing_star:
return
session.delete(existing_star)
def create_app(self, tenant_id: str, params: CreateAppParams, account: Account) -> App:
"""
Create app
@ -282,6 +415,7 @@ class AppService:
app_id=app.id,
name=params.name,
description=params.description or "",
role=params.agent_role,
icon_type=icon_type,
icon=params.icon,
icon_background=params.icon_background,
@ -377,6 +511,7 @@ class AppService:
icon_background: str
use_icon_as_answer_icon: bool
max_active_requests: int
role: NotRequired[str | None]
@staticmethod
def _get_backing_agent_for_update(app: App) -> Agent | None:
@ -408,6 +543,7 @@ class AppService:
icon_type: IconType | str | None = None,
icon: str | None = None,
icon_background: str | None = None,
role: str | None = None,
account_id: str | None = None,
updated_at: datetime | None = None,
) -> None:
@ -430,6 +566,8 @@ class AppService:
agent.icon = icon
if icon_background is not None:
agent.icon_background = icon_background
if role is not None:
agent.role = role
agent.updated_by = account_id
if updated_at is not None:
agent.updated_at = updated_at
@ -464,6 +602,7 @@ class AppService:
icon_type=app.icon_type,
icon=app.icon,
icon_background=app.icon_background,
role=args.get("role"),
account_id=current_user.id,
updated_at=app.updated_at,
)

View File

@ -43,10 +43,16 @@ class JinaAuth(ApiKeyAuthBase):
def _handle_error(self, response):
if response.status_code in {402, 409, 500}:
error_message = response.json().get("error", "Unknown error occurred")
try:
error_message = response.json().get("error", "Unknown error occurred")
except ValueError:
error_message = response.text or "Unknown error occurred"
raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}")
else:
if response.text:
error_message = json.loads(response.text).get("error", "Unknown error occurred")
try:
error_message = json.loads(response.text).get("error", "Unknown error occurred")
except ValueError:
error_message = response.text
raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}")
raise Exception(f"Unexpected error occurred while trying to authorize. Status code: {response.status_code}")

View File

@ -43,10 +43,16 @@ class JinaAuth(ApiKeyAuthBase):
def _handle_error(self, response):
if response.status_code in {402, 409, 500}:
error_message = response.json().get("error", "Unknown error occurred")
try:
error_message = response.json().get("error", "Unknown error occurred")
except ValueError:
error_message = response.text or "Unknown error occurred"
raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}")
else:
if response.text:
error_message = json.loads(response.text).get("error", "Unknown error occurred")
try:
error_message = json.loads(response.text).get("error", "Unknown error occurred")
except ValueError:
error_message = response.text
raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}")
raise Exception(f"Unexpected error occurred while trying to authorize. Status code: {response.status_code}")

View File

@ -37,10 +37,16 @@ class WatercrawlAuth(ApiKeyAuthBase):
def _handle_error(self, response):
if response.status_code in {402, 409, 500}:
error_message = response.json().get("error", "Unknown error occurred")
try:
error_message = response.json().get("error", "Unknown error occurred")
except ValueError:
error_message = response.text or "Unknown error occurred"
raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}")
else:
if response.text:
error_message = json.loads(response.text).get("error", "Unknown error occurred")
try:
error_message = json.loads(response.text).get("error", "Unknown error occurred")
except ValueError:
error_message = response.text
raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}")
raise Exception(f"Unexpected error occurred while trying to authorize. Status code: {response.status_code}")

View File

@ -192,6 +192,27 @@ class SimpleProviderEntityResponse(BaseModel):
return self
class ProviderEntityResponse(BaseModel):
"""Runtime provider response with codegen-safe model pricing schemas."""
provider: str
provider_name: str = ""
label: I18nObject
description: I18nObject | None = None
icon_small: I18nObject | None = None
icon_small_dark: I18nObject | None = None
background: str | None = None
help: ProviderHelpEntity | None = None
supported_model_types: Sequence[ModelType]
configurate_methods: list[ConfigurateMethod]
models: list[AIModelEntityResponse] = []
provider_credential_schema: ProviderCredentialSchema | None = None
model_credential_schema: ModelCredentialSchema | None = None
position: dict[str, list[str]] | None = {}
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
class DefaultModelResponse(BaseModel):
"""
Default model entity.

View File

@ -223,9 +223,12 @@ class HumanInputService:
if result.form_kind != HumanInputFormKind.RUNTIME:
return
if result.workflow_run_id is None:
return
self.enqueue_resume(result.workflow_run_id)
# A RUNTIME form is owned by a workflow run (workflow Agent node) or a
# conversation (ENG-635: Agent v2 chat). Route the resume accordingly.
if result.workflow_run_id is not None:
self.enqueue_resume(result.workflow_run_id)
elif result.conversation_id is not None:
self.enqueue_agent_app_resume(conversation_id=result.conversation_id, form_id=result.form_id)
def ensure_form_active(self, form: Form) -> None:
if form.submitted:
@ -286,6 +289,22 @@ class HumanInputService:
logger.warning("App mode %s does not support resume for workflow run %s", app.mode, workflow_run_id)
def enqueue_agent_app_resume(self, *, conversation_id: str, form_id: str) -> None:
"""ENG-635: resume an Agent v2 chat after its ask_human form is submitted.
Enqueues a background turn for the conversation; the Agent App runner
continues the agent run, threading the human's reply into the request as
``deferred_tool_results``.
"""
from tasks.app_generate.resume_agent_app_task import resume_agent_app_execution
try:
resume_agent_app_execution.apply_async(
kwargs={"conversation_id": conversation_id, "form_id": form_id},
)
except Exception: # pragma: no cover
logger.exception("Failed to enqueue Agent App resume for conversation %s form %s", conversation_id, form_id)
def _load_variable_pool_for_form(self, form: Form) -> ReadOnlyVariablePool | None:
workflow_run_id = form.workflow_run_id
if workflow_run_id is None:

View File

@ -1,18 +1,295 @@
"""Manage tenant plugin auto-upgrade strategies.
The storage is category-scoped: each tenant can have one strategy per plugin
category. Public mutation helpers require an explicit category so callers do
not accidentally overwrite every plugin type with one workspace-level policy.
"""
import logging
from dataclasses import dataclass
from hashlib import sha256
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from core.plugin.impl.plugin import PluginInstaller
from models.account import TenantPluginAutoUpgradeStrategy
logger = logging.getLogger(__name__)
PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory
PLUGIN_CATEGORIES = tuple(PluginCategory)
SECONDS_PER_DAY = 24 * 60 * 60
AUTO_UPGRADE_CHECK_SLOT_SECONDS = 15 * 60
AUTO_UPGRADE_CHECK_SLOT_COUNT = SECONDS_PER_DAY // AUTO_UPGRADE_CHECK_SLOT_SECONDS
@dataclass(frozen=True)
class PluginAutoUpgradeBackfillResult:
created_count: int
normalized: bool
class PluginAutoUpgradeService:
@staticmethod
def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None:
with session_factory.create_session() as session:
return session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
.limit(1)
def default_strategy_setting_for_category(
category: PluginCategory,
) -> TenantPluginAutoUpgradeStrategy.StrategySetting:
if category == PluginCategory.MODEL:
return TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
return TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
@staticmethod
def default_upgrade_time_of_day(tenant_id: str) -> int:
"""Spread default checks across 15-minute aligned slots by tenant."""
hash_input = tenant_id.encode()
slot = int.from_bytes(sha256(hash_input).digest()[:8], "big") % AUTO_UPGRADE_CHECK_SLOT_COUNT
return slot * AUTO_UPGRADE_CHECK_SLOT_SECONDS
@staticmethod
def _coerce_category(category: object) -> PluginCategory | None:
"""Accept daemon enum/string categories and ignore unknown values."""
category_value = getattr(category, "value", category)
if category_value is None:
return None
try:
return PluginCategory(str(category_value))
except ValueError:
return None
@staticmethod
def _get_installed_plugin_categories(tenant_id: str) -> dict[str, PluginCategory]:
"""Build a plugin_id -> category map for splitting legacy include/exclude lists."""
installed_plugins = PluginInstaller().list_plugins(tenant_id)
plugin_categories: dict[str, PluginCategory] = {}
for plugin in installed_plugins:
plugin_category = PluginAutoUpgradeService._coerce_category(plugin.declaration.category)
if plugin_category is not None:
plugin_categories[plugin.plugin_id] = plugin_category
return plugin_categories
@staticmethod
def _filter_plugin_ids_for_category(
plugin_ids: list[str],
category: PluginCategory,
plugin_categories: dict[str, PluginCategory],
) -> list[str]:
return [plugin_id for plugin_id in plugin_ids if plugin_categories.get(plugin_id) == category]
@staticmethod
def _log_unknown_plugin_ids(
tenant_id: str,
field_name: str,
plugin_ids: list[str],
plugin_categories: dict[str, PluginCategory],
) -> None:
unknown_plugin_ids = [plugin_id for plugin_id in plugin_ids if plugin_id not in plugin_categories]
if not unknown_plugin_ids:
return
logger.warning(
"Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: "
"tenant_id=%s, field=%s, plugin_ids=%s",
tenant_id,
field_name,
unknown_plugin_ids,
)
@staticmethod
def _has_default_strategy(strategy: TenantPluginAutoUpgradeStrategy) -> bool:
return (
strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
and strategy.upgrade_time_of_day == 0
and strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
and not strategy.exclude_plugins
and not strategy.include_plugins
)
@staticmethod
def _strategy_setting_for_category(
source_strategy: TenantPluginAutoUpgradeStrategy,
category: PluginCategory,
source_has_default_strategy: bool,
) -> TenantPluginAutoUpgradeStrategy.StrategySetting:
# Only pure legacy defaults adopt the new model=latest default. User-edited
# strategies keep their original setting across all categories.
if source_has_default_strategy:
return PluginAutoUpgradeService.default_strategy_setting_for_category(category)
return source_strategy.strategy_setting
@staticmethod
def _upgrade_time_of_day_for_category(
tenant_id: str,
source_strategy: TenantPluginAutoUpgradeStrategy,
source_has_default_strategy: bool,
) -> int:
# Pure legacy defaults are spread by tenant so all default rows do not
# concentrate in the same scheduler window. User-edited schedules keep their time.
if source_has_default_strategy:
return PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
return source_strategy.upgrade_time_of_day
@staticmethod
def backfill_strategy_categories(
tenant_id: str,
) -> PluginAutoUpgradeBackfillResult:
"""Create missing category strategies and split include/exclude lists when needed.
The historical row is treated as the workspace-level source strategy.
New category rows copy it first, then plugin lists are narrowed by real
plugin category when the source strategy contains include/exclude IDs.
"""
with session_factory.create_session() as session, session.begin():
strategies = list(
session.scalars(
select(TenantPluginAutoUpgradeStrategy).where(
TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id
)
).all()
)
if not strategies:
return PluginAutoUpgradeBackfillResult(created_count=0, normalized=False)
# Schema migration marks the historical workspace-level row as tool.
source_strategy = next(
(strategy for strategy in strategies if strategy.category == PluginCategory.TOOL),
strategies[0],
)
source_has_default_strategy = PluginAutoUpgradeService._has_default_strategy(source_strategy)
strategies_by_category = {strategy.category: strategy for strategy in strategies}
exclude_plugins = source_strategy.exclude_plugins
include_plugins = source_strategy.include_plugins
should_split_plugin_lists = bool(exclude_plugins or include_plugins)
# Query daemon only for tenants that actually customized plugin lists.
plugin_categories = (
PluginAutoUpgradeService._get_installed_plugin_categories(tenant_id)
if should_split_plugin_lists
else {}
)
if should_split_plugin_lists:
PluginAutoUpgradeService._log_unknown_plugin_ids(
tenant_id,
"exclude_plugins",
exclude_plugins,
plugin_categories,
)
PluginAutoUpgradeService._log_unknown_plugin_ids(
tenant_id,
"include_plugins",
include_plugins,
plugin_categories,
)
created_count = 0
for category in PLUGIN_CATEGORIES:
strategy = strategies_by_category.get(category)
if strategy is None:
# Start from the legacy workspace-level behavior before narrowing lists.
strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant_id,
category=category,
strategy_setting=PluginAutoUpgradeService._strategy_setting_for_category(
source_strategy, category, source_has_default_strategy
),
upgrade_time_of_day=PluginAutoUpgradeService._upgrade_time_of_day_for_category(
tenant_id, source_strategy, source_has_default_strategy
),
upgrade_mode=source_strategy.upgrade_mode,
exclude_plugins=source_strategy.exclude_plugins.copy(),
include_plugins=source_strategy.include_plugins.copy(),
)
session.add(strategy)
created_count += 1
elif source_has_default_strategy:
strategy.strategy_setting = PluginAutoUpgradeService.default_strategy_setting_for_category(
strategy.category
)
strategy.upgrade_time_of_day = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
if not should_split_plugin_lists:
continue
# Narrow include/exclude lists to the current category after all rows exist.
strategy.exclude_plugins = PluginAutoUpgradeService._filter_plugin_ids_for_category(
exclude_plugins,
strategy.category,
plugin_categories,
)
strategy.include_plugins = PluginAutoUpgradeService._filter_plugin_ids_for_category(
include_plugins,
strategy.category,
plugin_categories,
)
return PluginAutoUpgradeBackfillResult(created_count=created_count, normalized=should_split_plugin_lists)
@staticmethod
def _get_strategy(
session: Session,
tenant_id: str,
category: PluginCategory,
) -> TenantPluginAutoUpgradeStrategy | None:
return session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(
TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id,
TenantPluginAutoUpgradeStrategy.category == category,
)
.limit(1)
)
@staticmethod
def get_strategy(
tenant_id: str,
category: PluginCategory,
) -> TenantPluginAutoUpgradeStrategy | None:
with session_factory.create_session() as session:
return PluginAutoUpgradeService._get_strategy(session, tenant_id, category)
@staticmethod
def get_strategies(tenant_id: str) -> list[TenantPluginAutoUpgradeStrategy]:
with session_factory.create_session() as session:
return list(
session.scalars(
select(TenantPluginAutoUpgradeStrategy).where(
TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id
)
).all()
)
@staticmethod
def _change_strategy(
session: Session,
tenant_id: str,
category: PluginCategory,
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting,
upgrade_time_of_day: int,
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
) -> None:
exist_strategy = PluginAutoUpgradeService._get_strategy(session, tenant_id, category)
if not exist_strategy:
strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant_id,
category=category,
strategy_setting=strategy_setting,
upgrade_time_of_day=upgrade_time_of_day,
upgrade_mode=upgrade_mode,
exclude_plugins=exclude_plugins,
include_plugins=include_plugins,
)
session.add(strategy)
else:
exist_strategy.strategy_setting = strategy_setting
exist_strategy.upgrade_time_of_day = upgrade_time_of_day
exist_strategy.upgrade_mode = upgrade_mode
exist_strategy.exclude_plugins = exclude_plugins
exist_strategy.include_plugins = include_plugins
@staticmethod
def change_strategy(
@ -22,64 +299,72 @@ class PluginAutoUpgradeService:
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
category: PluginCategory,
) -> bool:
with session_factory.create_session() as session, session.begin():
exist_strategy = session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
.limit(1)
PluginAutoUpgradeService._change_strategy(
session,
tenant_id=tenant_id,
category=category,
strategy_setting=strategy_setting,
upgrade_time_of_day=upgrade_time_of_day,
upgrade_mode=upgrade_mode,
exclude_plugins=exclude_plugins,
include_plugins=include_plugins,
)
if not exist_strategy:
strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant_id,
strategy_setting=strategy_setting,
upgrade_time_of_day=upgrade_time_of_day,
upgrade_mode=upgrade_mode,
exclude_plugins=exclude_plugins,
include_plugins=include_plugins,
)
session.add(strategy)
else:
exist_strategy.strategy_setting = strategy_setting
exist_strategy.upgrade_time_of_day = upgrade_time_of_day
exist_strategy.upgrade_mode = upgrade_mode
exist_strategy.exclude_plugins = exclude_plugins
exist_strategy.include_plugins = include_plugins
return True
@staticmethod
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
with session_factory.create_session() as session, session.begin():
exist_strategy = session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
.limit(1)
def _exclude_plugin(
session: Session,
tenant_id: str,
category: PluginCategory,
plugin_id: str,
) -> None:
"""Remove one plugin from automatic updates for a single category strategy."""
exist_strategy = PluginAutoUpgradeService._get_strategy(session, tenant_id, category)
if not exist_strategy:
PluginAutoUpgradeService._change_strategy(
session,
tenant_id,
category,
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
0,
TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
[plugin_id],
[],
)
if not exist_strategy:
# create for this tenant
PluginAutoUpgradeService.change_strategy(
tenant_id,
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
0,
TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
[plugin_id],
[],
)
return True
else:
if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
if plugin_id not in exist_strategy.exclude_plugins:
new_exclude_plugins = exist_strategy.exclude_plugins.copy()
new_exclude_plugins.append(plugin_id)
exist_strategy.exclude_plugins = new_exclude_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL:
if plugin_id in exist_strategy.include_plugins:
new_include_plugins = exist_strategy.include_plugins.copy()
new_include_plugins.remove(plugin_id)
exist_strategy.include_plugins = new_include_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
exist_strategy.exclude_plugins = [plugin_id]
else:
if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
# In exclude mode, disabling one plugin means adding it to exclude_plugins.
if plugin_id not in exist_strategy.exclude_plugins:
new_exclude_plugins = exist_strategy.exclude_plugins.copy()
new_exclude_plugins.append(plugin_id)
exist_strategy.exclude_plugins = new_exclude_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL:
# In partial mode, disabling one plugin means removing it from include_plugins.
if plugin_id in exist_strategy.include_plugins:
new_include_plugins = exist_strategy.include_plugins.copy()
new_include_plugins.remove(plugin_id)
exist_strategy.include_plugins = new_include_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
# In all mode, switch to exclude mode so only this plugin is skipped.
exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
exist_strategy.exclude_plugins = [plugin_id]
return True
@staticmethod
def exclude_plugin(
tenant_id: str,
plugin_id: str,
category: PluginCategory,
) -> bool:
with session_factory.create_session() as session, session.begin():
PluginAutoUpgradeService._exclude_plugin(
session,
tenant_id,
category,
plugin_id,
)
return True

View File

@ -8,6 +8,7 @@ from uuid import uuid4
import yaml
from flask_login import current_user
from sqlalchemy import select
from sqlalchemy.orm import scoped_session
from configs import dify_config
from constants import DOCUMENT_EXTENSIONS
@ -28,8 +29,8 @@ logger = logging.getLogger(__name__)
class RagPipelineTransformService:
def transform_dataset(self, dataset_id: str):
dataset = db.session.get(Dataset, dataset_id)
def transform_dataset(self, dataset_id: str, session: scoped_session):
dataset = session.get(Dataset, dataset_id)
if not dataset:
raise ValueError("Dataset not found")
if dataset.pipeline_id and dataset.runtime_mode == DatasetRuntimeMode.RAG_PIPELINE:
@ -94,9 +95,9 @@ class RagPipelineTransformService:
dataset.pipeline_id = pipeline.id
# deal document data
self._deal_document_data(dataset)
self._deal_document_data(dataset, session)
db.session.commit()
session.commit()
return {
"pipeline_id": pipeline.id,
"dataset_id": dataset_id,
@ -310,13 +311,13 @@ class RagPipelineTransformService:
"status": "success",
}
def _deal_document_data(self, dataset: Dataset):
def _deal_document_data(self, dataset: Dataset, session: scoped_session):
file_node_id = "1752479895761"
notion_node_id = "1752489759475"
jina_node_id = "1752491761974"
firecrawl_node_id = "1752565402678"
documents = db.session.scalars(select(Document).where(Document.dataset_id == dataset.id)).all()
documents = session.scalars(select(Document).where(Document.dataset_id == dataset.id)).all()
for document in documents:
data_source_info_dict = document.data_source_info_dict
@ -326,7 +327,7 @@ class RagPipelineTransformService:
document.data_source_type = DataSourceType.LOCAL_FILE
file_id = data_source_info_dict.get("upload_file_id")
if file_id:
file = db.session.get(UploadFile, file_id)
file = session.get(UploadFile, file_id)
if file:
data_source_info = json.dumps(
{
@ -350,8 +351,8 @@ class RagPipelineTransformService:
datasource_node_id=file_node_id,
)
document_pipeline_execution_log.created_at = document.created_at
db.session.add(document)
db.session.add(document_pipeline_execution_log)
session.add(document)
session.add(document_pipeline_execution_log)
elif document.data_source_type == DataSourceType.NOTION_IMPORT:
document.data_source_type = DataSourceType.ONLINE_DOCUMENT
data_source_info = json.dumps(
@ -378,8 +379,8 @@ class RagPipelineTransformService:
datasource_node_id=notion_node_id,
)
document_pipeline_execution_log.created_at = document.created_at
db.session.add(document)
db.session.add(document_pipeline_execution_log)
session.add(document)
session.add(document_pipeline_execution_log)
elif document.data_source_type == DataSourceType.WEBSITE_CRAWL:
data_source_info = json.dumps(
{
@ -406,5 +407,5 @@ class RagPipelineTransformService:
datasource_node_id=datasource_node_id,
)
document_pipeline_execution_log.created_at = document.created_at
db.session.add(document)
db.session.add(document_pipeline_execution_log)
session.add(document)
session.add(document_pipeline_execution_log)

View File

@ -1,4 +1,4 @@
from typing import Any, TypedDict, override
from typing import Any, NotRequired, TypedDict, override
from sqlalchemy import select
@ -22,6 +22,7 @@ class RecommendedAppItemDict(TypedDict):
categories: list[str]
position: int
is_listed: bool
can_trial: NotRequired[bool]
class RecommendedAppsResultDict(TypedDict):
@ -64,14 +65,47 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
:param language: language
:return:
"""
recommended_apps = db.session.scalars(
select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.language == language)
).all()
recommended_apps = cls._fetch_listed_recommended_apps(language)
if len(recommended_apps) == 0:
recommended_apps = db.session.scalars(
select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.language == languages[0])
).all()
recommended_apps = cls._fetch_listed_recommended_apps(languages[0])
return cls._format_recommended_apps(recommended_apps, language)
@classmethod
def fetch_learn_dify_apps_from_db(cls, language: str) -> RecommendedAppsResultDict:
"""
Fetch listed recommended apps explicitly marked for the Learn Dify section.
:param language: language
:return:
"""
recommended_apps = cls._fetch_listed_recommended_apps(language, is_learn_dify=True)
if len(recommended_apps) == 0 and language != languages[0]:
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], is_learn_dify=True)
return cls._format_recommended_apps(recommended_apps, language)
@classmethod
def _fetch_listed_recommended_apps(
cls, language: str, *, is_learn_dify: bool | None = None
) -> list[RecommendedApp]:
filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language]
if is_learn_dify is not None:
filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify))
return list(db.session.scalars(select(RecommendedApp).where(*filters)).all())
@classmethod
def _format_recommended_apps(
cls, recommended_apps: list[RecommendedApp], language: str
) -> RecommendedAppsResultDict:
"""
Serialize DB recommended app rows into the Explore list response shape.
:param recommended_apps: recommended app rows
:param language: language used for category ordering
:return:
"""
categories = set()
recommended_apps_result: list[RecommendedAppItemDict] = []

View File

@ -6,6 +6,7 @@ from sqlalchemy.orm import scoped_session
from configs import dify_config
from models.model import AccountTrialAppRecord, TrialApp
from services.feature_service import FeatureService
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
@ -31,13 +32,24 @@ class RecommendedAppService:
apps = result["recommended_apps"]
for app in apps:
app_id = app["app_id"]
trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
if trial_app_model:
app["can_trial"] = True
else:
app["can_trial"] = False
app["can_trial"] = cls._can_trial_app(session, app_id)
return result
@classmethod
def get_learn_dify_apps(cls, session: scoped_session, language: str) -> dict[str, Any]:
"""
Get database-backed recommended apps marked as Learn Dify.
:param language: language
:return:
"""
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language)
if FeatureService.get_system_features().enable_trial_app:
for app in result["recommended_apps"]:
app["can_trial"] = cls._can_trial_app(session, app["app_id"])
return {"recommended_apps": result["recommended_apps"]}
@classmethod
def get_recommend_app_detail(cls, session: scoped_session, app_id: str) -> dict[str, Any] | None:
"""
@ -52,11 +64,7 @@ class RecommendedAppService:
return None
if FeatureService.get_system_features().enable_trial_app:
app_id = result["id"]
trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
if trial_app_model:
result["can_trial"] = True
else:
result["can_trial"] = False
result["can_trial"] = cls._can_trial_app(session, app_id)
return result
@classmethod
@ -77,3 +85,8 @@ class RecommendedAppService:
else:
session.add(AccountTrialAppRecord(app_id=app_id, count=1, account_id=account_id))
session.commit()
@staticmethod
def _can_trial_app(session: scoped_session, app_id: str) -> bool:
trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
return trial_app_model is not None

View File

@ -6,7 +6,7 @@ from datetime import UTC, datetime
from enum import StrEnum
from urllib.parse import urlparse
import yaml # type: ignore
import yaml
from packaging import version
from pydantic import BaseModel, Field
from sqlalchemy import select
@ -498,7 +498,7 @@ class SnippetDslService:
export_data=export_data, snippet=snippet, workflow=workflow, include_secret=include_secret
)
return yaml.dump(export_data, allow_unicode=True) # type: ignore
return yaml.dump(export_data, allow_unicode=True)
def _append_workflow_export_data(
self, *, export_data: dict, snippet: CustomizedSnippet, workflow: Workflow, include_secret: bool

View File

@ -323,7 +323,7 @@ class WorkflowService:
from services.agent.workflow_publish_service import WorkflowAgentPublishService
db.session.flush()
WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft(
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=cast(Session, db.session),
draft_workflow=workflow,
account_id=account.id,

View File

@ -0,0 +1,70 @@
"""Background resume of an Agent v2 chat after a submitted ask_human HITL form.
ENG-635. When a human submits a conversation-owned ask_human form (delivered via
email/webapp), ``HumanInputService`` enqueues this task. It reconstructs the
conversation context and runs one blocking Agent App turn; the runner detects the
answered form and continues the agent run with the human's reply
(``deferred_tool_results``), persisting the assistant answer to the conversation.
"""
from __future__ import annotations
import logging
from celery import shared_task
from core.app.apps.agent_app.app_generator import AgentAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from models.account import Account
from models.human_input import HumanInputForm
from models.model import App, Conversation, EndUser
from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE
logger = logging.getLogger(__name__)
@shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE, name="resume_agent_app_execution")
def resume_agent_app_execution(*, conversation_id: str, form_id: str) -> None:
form = db.session.get(HumanInputForm, form_id)
if form is None or form.conversation_id != conversation_id:
logger.warning("Agent App resume: form %s missing or conversation mismatch", form_id)
return
app_model = db.session.get(App, form.app_id)
if app_model is None:
logger.warning("Agent App resume: app %s not found for form %s", form.app_id, form_id)
return
conversation = db.session.get(Conversation, conversation_id)
if conversation is None:
logger.warning("Agent App resume: conversation %s not found", conversation_id)
return
user = _resolve_conversation_user(app_model=app_model, conversation=conversation)
if user is None:
logger.warning("Agent App resume: no user resolvable for conversation %s", conversation_id)
return
try:
AgentAppGenerator().resume_after_form_submission(
app_model=app_model,
user=user,
conversation_id=conversation_id,
invoke_from=InvokeFrom.WEB_APP,
)
except Exception:
logger.exception("Agent App resume failed for conversation %s form %s", conversation_id, form_id)
finally:
db.session.close()
def _resolve_conversation_user(*, app_model: App, conversation: Conversation) -> Account | EndUser | None:
if conversation.from_account_id:
account = db.session.get(Account, conversation.from_account_id)
if account is not None:
account.set_tenant_id(app_model.tenant_id)
return account
if conversation.from_end_user_id:
return db.session.get(EndUser, conversation.from_end_user_id)
return None

View File

@ -95,16 +95,30 @@ def check_and_handle_human_input_timeouts(limit: int = 100) -> None:
timeout_status=HumanInputFormStatus.EXPIRED if is_global else HumanInputFormStatus.TIMEOUT,
reason="global_timeout" if is_global else "node_timeout",
)
assert record.workflow_run_id is not None, "workflow_run_id should not be None for non-test form"
if is_global:
# Global timeout applies only to workflow-owned forms
# (_is_global_timeout requires a workflow_run_id): end the run.
assert record.workflow_run_id is not None, "global timeout requires a workflow_run_id"
_handle_global_timeout(
form_id=record.form_id,
workflow_run_id=record.workflow_run_id,
node_id=record.node_id,
session_factory=session_factory,
)
else:
elif record.workflow_run_id is not None:
# Workflow Agent node / Human Input node form: resume the workflow.
service.enqueue_resume(record.workflow_run_id)
elif record.conversation_id is not None:
# ENG-635: Agent v2 chat ask_human form is conversation-owned (no
# workflow_run_id). Resume the chat turn so the timeout is threaded
# back to the agent run as the ask_human deferred_tool_result
# (status="timeout"), mirroring HumanInputService.submit_form_by_token.
service.enqueue_agent_app_resume(conversation_id=record.conversation_id, form_id=record.form_id)
else:
logger.warning(
"Timed-out form %s has neither workflow_run_id nor conversation_id; skipping resume",
record.form_id,
)
except Exception:
logger.exception(
"Failed to handle timeout for form_id=%s workflow_run_id=%s",

View File

@ -7,7 +7,7 @@ import click
from celery import shared_task
from core.plugin.entities.marketplace import MarketplacePluginSnapshot
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin import PluginInstallation, PluginInstallationSource
from core.plugin.impl.plugin import PluginInstaller
from core.plugin.plugin_service import PluginService
from extensions.ext_redis import redis_client
@ -15,6 +15,7 @@ from models.account import TenantPluginAutoUpgradeStrategy
logger = logging.getLogger(__name__)
PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory
RETRY_TIMES_OF_ONE_PLUGIN_IN_ONE_TENANT = 3
CACHE_REDIS_KEY_PREFIX = "plugin_autoupgrade_check_task:cached_plugin_snapshot:"
CACHE_REDIS_TTL = 60 * 60 # 1 hour
@ -72,6 +73,25 @@ def marketplace_batch_fetch_plugin_manifests(
return result
def _normalize_category(category: PluginCategory | str | None) -> str | None:
if category is None:
return None
if isinstance(category, PluginCategory):
return category.value
return str(category)
def _plugin_matches_category(plugin: PluginInstallation, category: str | None) -> bool:
"""Return whether an installed plugin should be checked by a category strategy."""
if category is None:
return True
declaration = getattr(plugin, "declaration", None)
plugin_category = getattr(declaration, "category", None)
plugin_category_value = getattr(plugin_category, "value", plugin_category)
return plugin_category_value == category
@shared_task(queue="plugin")
def process_tenant_plugin_autoupgrade_check_task(
tenant_id: str,
@ -80,13 +100,15 @@ def process_tenant_plugin_autoupgrade_check_task(
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
category: PluginCategory | str | None = None,
):
try:
manager = PluginInstaller()
category_value = _normalize_category(category)
click.echo(
click.style(
f"Checking upgradable plugin for tenant: {tenant_id}",
f"Checking upgradable plugin for tenant: {tenant_id}, category: {category_value or 'all'}",
fg="green",
)
)
@ -102,7 +124,11 @@ def process_tenant_plugin_autoupgrade_check_task(
all_plugins = manager.list_plugins(tenant_id)
for plugin in all_plugins:
if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id in include_plugins:
if (
plugin.source == PluginInstallationSource.Marketplace
and plugin.plugin_id in include_plugins
and _plugin_matches_category(plugin, category_value)
):
plugin_ids.append(
(
plugin.plugin_id,
@ -117,7 +143,9 @@ def process_tenant_plugin_autoupgrade_check_task(
plugin_ids = [
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
for plugin in all_plugins
if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id not in exclude_plugins
if plugin.source == PluginInstallationSource.Marketplace
and plugin.plugin_id not in exclude_plugins
and _plugin_matches_category(plugin, category_value)
]
elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
all_plugins = manager.list_plugins(tenant_id)
@ -125,6 +153,7 @@ def process_tenant_plugin_autoupgrade_check_task(
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
for plugin in all_plugins
if plugin.source == PluginInstallationSource.Marketplace
and _plugin_matches_category(plugin, category_value)
]
if not plugin_ids:

View File

@ -22,6 +22,7 @@ from models import (
AppDatasetJoin,
AppMCPServer,
AppModelConfig,
AppStar,
AppTrigger,
Conversation,
EndUser,
@ -64,6 +65,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
_delete_app_mcp_servers(tenant_id, app_id)
_delete_app_api_tokens(tenant_id, app_id)
_delete_installed_apps(tenant_id, app_id)
_delete_app_stars(tenant_id, app_id)
_delete_recommended_apps(tenant_id, app_id)
_delete_app_annotation_data(tenant_id, app_id)
_delete_app_dataset_joins(tenant_id, app_id)
@ -173,6 +175,18 @@ def _delete_installed_apps(tenant_id: str, app_id: str):
)
def _delete_app_stars(tenant_id: str, app_id: str):
def del_app_star(session, app_star_id: str):
session.execute(delete(AppStar).where(AppStar.id == app_star_id).execution_options(synchronize_session=False))
_delete_records(
"""select id from app_stars where tenant_id=:tenant_id and app_id=:app_id limit 1000""",
{"tenant_id": tenant_id, "app_id": app_id},
del_app_star,
"app star",
)
def _delete_recommended_apps(tenant_id: str, app_id: str):
def del_recommended_app(session, recommended_app_id: str):
session.execute(

View File

@ -101,11 +101,13 @@ def _create_workflow_run_from_execution(
workflow_run.triggered_from = triggered_from
workflow_run.version = execution.workflow_version
json_converter = WorkflowRuntimeTypeConverter()
workflow_run.graph = json.dumps(json_converter.to_json_encodable(execution.graph))
workflow_run.inputs = json.dumps(json_converter.to_json_encodable(execution.inputs))
workflow_run.graph = json.dumps(json_converter.to_json_encodable(execution.graph), ensure_ascii=False)
workflow_run.inputs = json.dumps(json_converter.to_json_encodable(execution.inputs), ensure_ascii=False)
workflow_run.status = execution.status
workflow_run.outputs = (
json.dumps(json_converter.to_json_encodable(execution.outputs)) if execution.outputs else "{}"
json.dumps(json_converter.to_json_encodable(execution.outputs), ensure_ascii=False)
if execution.outputs
else "{}"
)
workflow_run.error = execution.error_message
workflow_run.elapsed_time = execution.elapsed_time

View File

@ -7,6 +7,8 @@ from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermissi
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_permission_service import PluginPermissionService
PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL
@pytest.fixture
def tenant(flask_req_ctx):
@ -71,7 +73,7 @@ class TestPluginPermissionLifecycle:
class TestPluginAutoUpgradeLifecycle:
def test_get_returns_none_for_new_tenant(self, tenant):
assert PluginAutoUpgradeService.get_strategy(tenant) is None
assert PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) is None
def test_change_creates_row(self, tenant):
result = PluginAutoUpgradeService.change_strategy(
@ -81,10 +83,11 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
exclude_plugins=[],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
assert result is True
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
assert strategy.upgrade_time_of_day == 3
@ -97,6 +100,7 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
exclude_plugins=[],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.change_strategy(
tenant,
@ -105,9 +109,10 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
exclude_plugins=[],
include_plugins=["plugin-a"],
category=PLUGIN_CATEGORY,
)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
assert strategy.upgrade_time_of_day == 12
@ -115,9 +120,9 @@ class TestPluginAutoUpgradeLifecycle:
assert strategy.include_plugins == ["plugin-a"]
def test_exclude_plugin_creates_strategy_when_none_exists(self, tenant):
PluginAutoUpgradeService.exclude_plugin(tenant, "my-plugin")
PluginAutoUpgradeService.exclude_plugin(tenant, "my-plugin", PLUGIN_CATEGORY)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
assert "my-plugin" in strategy.exclude_plugins
@ -130,10 +135,11 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=["existing"],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.exclude_plugin(tenant, "new-plugin")
PluginAutoUpgradeService.exclude_plugin(tenant, "new-plugin", PLUGIN_CATEGORY)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert "existing" in strategy.exclude_plugins
assert "new-plugin" in strategy.exclude_plugins
@ -146,10 +152,11 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=["same-plugin"],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.exclude_plugin(tenant, "same-plugin")
PluginAutoUpgradeService.exclude_plugin(tenant, "same-plugin", PLUGIN_CATEGORY)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.exclude_plugins.count("same-plugin") == 1
@ -161,10 +168,11 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
exclude_plugins=[],
include_plugins=["p1", "p2"],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.exclude_plugin(tenant, "p1")
PluginAutoUpgradeService.exclude_plugin(tenant, "p1", PLUGIN_CATEGORY)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert "p1" not in strategy.include_plugins
assert "p2" in strategy.include_plugins
@ -177,10 +185,11 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
exclude_plugins=[],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.exclude_plugin(tenant, "excluded-plugin")
PluginAutoUpgradeService.exclude_plugin(tenant, "excluded-plugin", PLUGIN_CATEGORY)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
assert "excluded-plugin" in strategy.exclude_plugins

View File

@ -51,6 +51,7 @@ def _create_recommended_app(
categories: list[str] | None = None,
language: str = "en-US",
is_listed: bool = True,
is_learn_dify: bool = False,
position: int = 1,
) -> RecommendedApp:
rec = RecommendedApp(
@ -62,6 +63,7 @@ def _create_recommended_app(
categories=[category] if categories is None else categories,
language=language,
is_listed=is_listed,
is_learn_dify=is_learn_dify,
position=position,
)
rec.id = str(uuid4())
@ -205,6 +207,65 @@ class TestFetchRecommendedAppsFromDb:
app_ids = {r["app_id"] for r in result["recommended_apps"]}
assert app1.id not in app_ids
def test_fetch_learn_dify_apps_uses_flag_not_categories(
self,
flask_app_with_containers,
db_session_with_containers: Session,
):
tenant_id = str(uuid4())
learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
_create_site(db_session_with_containers, app_id=learn_dify_app.id)
_create_recommended_app(
db_session_with_containers,
app_id=learn_dify_app.id,
category="workflow",
categories=["Workflow"],
is_learn_dify=True,
)
category_only_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
_create_site(db_session_with_containers, app_id=category_only_app.id)
_create_recommended_app(
db_session_with_containers,
app_id=category_only_app.id,
category="Learn Dify",
categories=["Learn Dify"],
is_learn_dify=False,
)
db_session_with_containers.expire_all()
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db("en-US")
app_ids = {r["app_id"] for r in result["recommended_apps"]}
assert learn_dify_app.id in app_ids
assert category_only_app.id not in app_ids
recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == learn_dify_app.id)
assert recommended_app["categories"] == ["Workflow"]
def test_fetch_learn_dify_apps_falls_back_to_default_language(
self,
flask_app_with_containers,
db_session_with_containers: Session,
):
tenant_id = str(uuid4())
learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
_create_site(db_session_with_containers, app_id=learn_dify_app.id)
_create_recommended_app(
db_session_with_containers,
app_id=learn_dify_app.id,
categories=["Workflow"],
is_learn_dify=True,
language="en-US",
)
db_session_with_containers.expire_all()
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db("fr-FR")
app_ids = {r["app_id"] for r in result["recommended_apps"]}
assert learn_dify_app.id in app_ids
class TestFetchRecommendedAppDetailFromDb:
def test_returns_none_when_not_listed(self, flask_app_with_containers: Flask, db_session_with_containers: Session):

View File

@ -1,6 +1,8 @@
from datetime import datetime
from unittest.mock import create_autospec, patch
import pytest
import sqlalchemy as sa
from faker import Faker
from pydantic import ValidationError
from sqlalchemy.orm import Session
@ -245,6 +247,236 @@ class TestAppService:
assert app.tenant_id == tenant.id
assert app.mode == "chat"
def test_get_paginate_apps_sorts_by_modified_and_created_times(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app list sort options for modified time and creation time.
"""
fake = Faker()
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=generate_valid_password(fake),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
from services.app_service import AppListParams, AppService, CreateAppParams
app_service = AppService()
oldest_created = app_service.create_app(
tenant.id,
CreateAppParams(name="Oldest Created", mode="chat", icon_type="emoji", icon="1"),
account,
)
newest_modified = app_service.create_app(
tenant.id,
CreateAppParams(name="Newest Modified", mode="chat", icon_type="emoji", icon="2"),
account,
)
newest_created = app_service.create_app(
tenant.id,
CreateAppParams(name="Newest Created", mode="chat", icon_type="emoji", icon="3"),
account,
)
timestamp_by_app_id = {
oldest_created.id: (datetime(2026, 1, 1, 10, 0, 0), datetime(2026, 1, 1, 10, 0, 0)),
newest_modified.id: (datetime(2026, 1, 2, 10, 0, 0), datetime(2026, 1, 4, 10, 0, 0)),
newest_created.id: (datetime(2026, 1, 3, 10, 0, 0), datetime(2026, 1, 3, 10, 0, 0)),
}
for app_id, (created_at, updated_at) in timestamp_by_app_id.items():
db_session_with_containers.execute(
sa.update(App).where(App.id == app_id).values(created_at=created_at, updated_at=updated_at)
)
db_session_with_containers.commit()
last_modified_apps = app_service.get_paginate_apps(
account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat")
)
assert last_modified_apps is not None
assert [app.name for app in last_modified_apps.items] == [
"Newest Modified",
"Newest Created",
"Oldest Created",
]
recently_created_apps = app_service.get_paginate_apps(
account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", sort_by="recently_created")
)
assert recently_created_apps is not None
assert [app.name for app in recently_created_apps.items] == [
"Newest Created",
"Newest Modified",
"Oldest Created",
]
earliest_created_apps = app_service.get_paginate_apps(
account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created")
)
assert earliest_created_apps is not None
assert [app.name for app in earliest_created_apps.items] == [
"Oldest Created",
"Newest Modified",
"Newest Created",
]
def test_get_paginate_apps_marks_starred_apps(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app list marks apps starred by the current account.
"""
fake = Faker()
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=generate_valid_password(fake),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
from models import AppStar
from services.app_service import AppListParams, AppService, CreateAppParams
app_service = AppService()
starred_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Starred App", mode="chat", icon_type="emoji", icon="1"),
account,
)
unstarred_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Unstarred App", mode="chat", icon_type="emoji", icon="2"),
account,
)
app_service.star_app(db_session_with_containers, app=starred_app, account_id=account.id)
app_service.star_app(db_session_with_containers, app=starred_app, account_id=account.id)
db_session_with_containers.commit()
star_count = db_session_with_containers.scalar(
sa.select(sa.func.count()).select_from(AppStar).where(AppStar.app_id == starred_app.id)
)
assert star_count == 1
paginated_apps = app_service.get_paginate_apps(
account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat")
)
assert paginated_apps is not None
starred_by_app_id = {app.id: app.is_starred for app in paginated_apps.items}
assert starred_by_app_id[starred_app.id] is True
assert starred_by_app_id[unstarred_app.id] is False
app_service.unstar_app(db_session_with_containers, app=starred_app, account_id=account.id)
db_session_with_containers.commit()
paginated_apps = app_service.get_paginate_apps(
account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat")
)
assert paginated_apps is not None
starred_by_app_id = {app.id: app.is_starred for app in paginated_apps.items}
assert starred_by_app_id[starred_app.id] is False
assert starred_by_app_id[unstarred_app.id] is False
def test_get_paginate_starred_apps_returns_only_starred_apps_with_requested_sort(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test starred app list returns only starred apps ordered by requested app sort.
"""
fake = Faker()
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=generate_valid_password(fake),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
from services.app_service import AppService, CreateAppParams, StarredAppListParams
app_service = AppService()
oldest_created_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Oldest Created Starred App", mode="chat", icon_type="emoji", icon="1"),
account,
)
newest_modified_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Newest Modified Starred App", mode="chat", icon_type="emoji", icon="2"),
account,
)
newest_created_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Newest Created Starred App", mode="chat", icon_type="emoji", icon="3"),
account,
)
unstarred_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Unstarred App", mode="chat", icon_type="emoji", icon="4"),
account,
)
app_service.star_app(db_session_with_containers, app=oldest_created_app, account_id=account.id)
app_service.star_app(db_session_with_containers, app=newest_modified_app, account_id=account.id)
app_service.star_app(db_session_with_containers, app=newest_created_app, account_id=account.id)
timestamp_by_app_id = {
oldest_created_app.id: (datetime(2026, 1, 1, 10, 0, 0), datetime(2026, 1, 1, 10, 0, 0)),
newest_modified_app.id: (datetime(2026, 1, 2, 10, 0, 0), datetime(2026, 1, 4, 10, 0, 0)),
newest_created_app.id: (datetime(2026, 1, 3, 10, 0, 0), datetime(2026, 1, 3, 10, 0, 0)),
unstarred_app.id: (datetime(2026, 1, 5, 10, 0, 0), datetime(2026, 1, 5, 10, 0, 0)),
}
for app_id, (created_at, updated_at) in timestamp_by_app_id.items():
db_session_with_containers.execute(
sa.update(App).where(App.id == app_id).values(created_at=created_at, updated_at=updated_at)
)
db_session_with_containers.commit()
last_modified_apps = app_service.get_paginate_starred_apps(
account.id, tenant.id, StarredAppListParams(page=1, limit=10, mode="chat")
)
assert last_modified_apps is not None
assert [app.name for app in last_modified_apps.items] == [
"Newest Modified Starred App",
"Newest Created Starred App",
"Oldest Created Starred App",
]
assert all(app.is_starred for app in last_modified_apps.items)
assert unstarred_app.id not in {app.id for app in last_modified_apps.items}
recently_created_apps = app_service.get_paginate_starred_apps(
account.id,
tenant.id,
StarredAppListParams(page=1, limit=10, mode="chat", sort_by="recently_created"),
)
assert recently_created_apps is not None
assert [app.name for app in recently_created_apps.items] == [
"Newest Created Starred App",
"Newest Modified Starred App",
"Oldest Created Starred App",
]
earliest_created_apps = app_service.get_paginate_starred_apps(
account.id,
tenant.id,
StarredAppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created"),
)
assert earliest_created_apps is not None
assert [app.name for app in earliest_created_apps.items] == [
"Oldest Created Starred App",
"Newest Modified Starred App",
"Newest Created Starred App",
]
def test_get_paginate_apps_with_filters(
self, db_session_with_containers: Session, mock_external_service_dependencies
):

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from unittest.mock import patch
from uuid import uuid4
@ -104,7 +105,9 @@ class TestEndUserServiceGetOrCreateEndUser:
"""Provide test data factory."""
return TestEndUserServiceFactory()
def test_get_or_create_end_user_with_custom_user_id(self, db_session_with_containers: Session, factory):
def test_get_or_create_end_user_with_custom_user_id(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
"""Test getting or creating end user with custom user_id."""
# Arrange
app = factory.create_app_and_account(db_session_with_containers)
@ -120,7 +123,9 @@ class TestEndUserServiceGetOrCreateEndUser:
assert result.type == InvokeFrom.SERVICE_API
assert result.is_anonymous is False
def test_get_or_create_end_user_without_user_id(self, db_session_with_containers: Session, factory):
def test_get_or_create_end_user_without_user_id(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
"""Test getting or creating end user without user_id uses default session."""
# Arrange
app = factory.create_app_and_account(db_session_with_containers)
@ -133,7 +138,7 @@ class TestEndUserServiceGetOrCreateEndUser:
# Verify _is_anonymous is set correctly (property always returns False)
assert result._is_anonymous is True
def test_get_existing_end_user(self, db_session_with_containers: Session, factory):
def test_get_existing_end_user(self, db_session_with_containers: Session, factory: TestEndUserServiceFactory):
"""Test retrieving an existing end user."""
# Arrange
app = factory.create_app_and_account(db_session_with_containers)
@ -169,7 +174,9 @@ class TestEndUserServiceGetOrCreateEndUserByType:
"""Provide test data factory."""
return TestEndUserServiceFactory()
def test_create_end_user_service_api_type(self, db_session_with_containers: Session, factory):
def test_create_end_user_service_api_type(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
"""Test creating new end user with SERVICE_API type."""
# Arrange
app = factory.create_app_and_account(db_session_with_containers)
@ -191,7 +198,9 @@ class TestEndUserServiceGetOrCreateEndUserByType:
assert result.app_id == app_id
assert result.session_id == user_id
def test_create_end_user_web_app_type(self, db_session_with_containers: Session, factory):
def test_create_end_user_web_app_type(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
"""Test creating new end user with WEB_APP type."""
# Arrange
app = factory.create_app_and_account(db_session_with_containers)
@ -210,8 +219,9 @@ class TestEndUserServiceGetOrCreateEndUserByType:
# Assert
assert result.type == InvokeFrom.WEB_APP
@patch("services.end_user_service.logger")
def test_upgrade_legacy_end_user_type(self, mock_logger, db_session_with_containers: Session, factory):
def test_upgrade_legacy_end_user_type(
self, caplog: pytest.LogCaptureFixture, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
"""Test upgrading legacy end user with different type."""
# Arrange
app = factory.create_app_and_account(db_session_with_containers)
@ -227,25 +237,32 @@ class TestEndUserServiceGetOrCreateEndUserByType:
session_id=user_id,
invoke_type=InvokeFrom.SERVICE_API,
)
# Act - Request with different type
result = EndUserService.get_or_create_end_user_by_type(
type=InvokeFrom.WEB_APP,
tenant_id=tenant_id,
app_id=app_id,
user_id=user_id,
)
with caplog.at_level(logging.INFO, logger="services.end_user_service"):
# Act - Request with different type
result = EndUserService.get_or_create_end_user_by_type(
type=InvokeFrom.WEB_APP,
tenant_id=tenant_id,
app_id=app_id,
user_id=user_id,
)
# Assert
assert result.id == existing_user.id
assert result.type == InvokeFrom.WEB_APP # Type should be updated
mock_logger.info.assert_called_once()
# Verify log message contains upgrade info
log_call = mock_logger.info.call_args[0][0]
assert "Upgrading legacy EndUser" in log_call
matching_logs = [
record
for record in caplog.records
if record.name == "services.end_user_service"
and record.levelno == logging.INFO
and "Upgrading legacy EndUser" in record.message
]
assert len(matching_logs) == 1
@patch("services.end_user_service.logger")
def test_get_existing_end_user_matching_type(self, mock_logger, db_session_with_containers: Session, factory):
def test_get_existing_end_user_matching_type(
self, mock_logger, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
"""Test retrieving existing end user with matching type."""
# Arrange
app = factory.create_app_and_account(db_session_with_containers)
@ -274,7 +291,9 @@ class TestEndUserServiceGetOrCreateEndUserByType:
assert result.type == InvokeFrom.SERVICE_API
mock_logger.info.assert_not_called()
def test_create_anonymous_user_with_default_session(self, db_session_with_containers: Session, factory):
def test_create_anonymous_user_with_default_session(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
"""Test creating anonymous user when user_id is None."""
# Arrange
app = factory.create_app_and_account(db_session_with_containers)
@ -295,7 +314,9 @@ class TestEndUserServiceGetOrCreateEndUserByType:
assert result._is_anonymous is True
assert result.external_user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID
def test_query_ordering_prioritizes_matching_type(self, db_session_with_containers: Session, factory):
def test_query_ordering_prioritizes_matching_type(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
"""Test that query ordering prioritizes records with matching type."""
# Arrange
app = factory.create_app_and_account(db_session_with_containers)
@ -330,7 +351,9 @@ class TestEndUserServiceGetOrCreateEndUserByType:
assert result.id == matching.id
assert result.id != non_matching.id
def test_external_user_id_matches_session_id(self, db_session_with_containers: Session, factory):
def test_external_user_id_matches_session_id(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
"""Test that external_user_id is set to match session_id."""
# Arrange
app = factory.create_app_and_account(db_session_with_containers)
@ -360,7 +383,7 @@ class TestEndUserServiceGetOrCreateEndUserByType:
],
)
def test_create_end_user_with_different_invoke_types(
self, db_session_with_containers: Session, invoke_type, factory
self, db_session_with_containers: Session, invoke_type: InvokeFrom, factory: TestEndUserServiceFactory
):
"""Test creating end users with different InvokeFrom types."""
# Arrange
@ -389,7 +412,9 @@ class TestEndUserServiceGetEndUserById:
"""Provide test data factory."""
return TestEndUserServiceFactory()
def test_get_end_user_by_id_returns_end_user(self, db_session_with_containers: Session, factory):
def test_get_end_user_by_id_returns_end_user(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
app = factory.create_app_and_account(db_session_with_containers)
existing_user = factory.create_end_user(
db_session_with_containers,
@ -408,7 +433,9 @@ class TestEndUserServiceGetEndUserById:
assert result is not None
assert result.id == existing_user.id
def test_get_end_user_by_id_returns_none(self, db_session_with_containers: Session, factory):
def test_get_end_user_by_id_returns_none(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
app = factory.create_app_and_account(db_session_with_containers)
result = EndUserService.get_end_user_by_id(
@ -427,7 +454,9 @@ class TestEndUserServiceCreateBatch:
def factory(self):
return TestEndUserServiceFactory()
def _create_multiple_apps(self, db_session_with_containers: Session, factory, count: int = 3):
def _create_multiple_apps(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, count: int = 3
):
"""Create multiple apps under the same tenant."""
first_app = factory.create_app_and_account(db_session_with_containers)
tenant_id = first_app.tenant_id
@ -462,7 +491,9 @@ class TestEndUserServiceCreateBatch:
)
assert result == {}
def test_create_batch_creates_users_for_all_apps(self, db_session_with_containers: Session, factory):
def test_create_batch_creates_users_for_all_apps(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=3)
app_ids = [a.id for a in apps]
user_id = f"user-{uuid4()}"
@ -477,7 +508,9 @@ class TestEndUserServiceCreateBatch:
assert result[app_id].session_id == user_id
assert result[app_id].type == InvokeFrom.SERVICE_API
def test_create_batch_default_session_id(self, db_session_with_containers: Session, factory):
def test_create_batch_default_session_id(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2)
app_ids = [a.id for a in apps]
@ -490,7 +523,9 @@ class TestEndUserServiceCreateBatch:
assert end_user.session_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID
assert end_user._is_anonymous is True
def test_create_batch_deduplicate_app_ids(self, db_session_with_containers: Session, factory):
def test_create_batch_deduplicate_app_ids(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2)
app_ids = [apps[0].id, apps[1].id, apps[0].id, apps[1].id]
user_id = f"user-{uuid4()}"
@ -501,7 +536,9 @@ class TestEndUserServiceCreateBatch:
assert len(result) == 2
def test_create_batch_returns_existing_users(self, db_session_with_containers: Session, factory):
def test_create_batch_returns_existing_users(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2)
app_ids = [a.id for a in apps]
user_id = f"user-{uuid4()}"
@ -520,7 +557,9 @@ class TestEndUserServiceCreateBatch:
for app_id in app_ids:
assert first_result[app_id].id == second_result[app_id].id
def test_create_batch_partial_existing_users(self, db_session_with_containers: Session, factory):
def test_create_batch_partial_existing_users(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=3)
user_id = f"user-{uuid4()}"
@ -549,7 +588,9 @@ class TestEndUserServiceCreateBatch:
"invoke_type",
[InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP, InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER],
)
def test_create_batch_all_invoke_types(self, db_session_with_containers: Session, invoke_type, factory):
def test_create_batch_all_invoke_types(
self, db_session_with_containers: Session, invoke_type: InvokeFrom, factory: TestEndUserServiceFactory
):
tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=1)
user_id = f"user-{uuid4()}"

View File

@ -263,6 +263,54 @@ class TestRecommendedAppServiceGetDetail:
mock_factory_class.get_recommend_app_factory.assert_called_with(mode)
# ── Pure logic tests: get_learn_dify_apps ──────────────────────────────
class TestRecommendedAppServiceGetLearnDifyApps:
def test_returns_database_learn_dify_apps_without_remote_factory(self, monkeypatch: pytest.MonkeyPatch) -> None:
expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow")
mock_database_retrieval = MagicMock()
mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = {
"recommended_apps": [expected_app],
"categories": ["Workflow"],
}
monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SimpleNamespace(enable_trial_app=False)),
)
factory_mock = MagicMock()
monkeypatch.setattr(service_module.RecommendAppRetrievalFactory, "get_recommend_app_factory", factory_mock)
result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US")
assert result == {"recommended_apps": [expected_app]}
mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US")
factory_mock.assert_not_called()
def test_sets_can_trial_when_trial_feature_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None:
app = RecommendedAppPayload(app_id="app-1", category="Workflow")
mock_database_retrieval = MagicMock()
mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = {
"recommended_apps": [app],
"categories": ["Workflow"],
}
monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SimpleNamespace(enable_trial_app=True)),
)
can_trial_mock = MagicMock(return_value=True)
monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock)
result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US")
assert result["recommended_apps"][0]["can_trial"] is True
can_trial_mock.assert_called_once_with(db.session, "app-1")
# ── Integration tests: trial app features (real DB) ────────────────────

View File

@ -327,3 +327,49 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
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.env[0].name == "APP_ENV"
# ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ─────
def test_ask_human_layer_injected_when_configured():
from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig
from clients.agent_backend.request_builder import DIFY_ASK_HUMAN_LAYER_ID
run_input = _run_input().model_copy(update={"ask_human_config": DifyAskHumanLayerConfig()})
request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input)
layers = {layer.name: layer for layer in request.composition.layers}
assert DIFY_ASK_HUMAN_LAYER_ID in layers
assert layers[DIFY_ASK_HUMAN_LAYER_ID].type == DIFY_ASK_HUMAN_LAYER_TYPE_ID
# the deferred tool needs the history layer to resume, so history must precede it
names = [layer.name for layer in request.composition.layers]
assert names.index(DIFY_AGENT_HISTORY_LAYER_ID) < names.index(DIFY_ASK_HUMAN_LAYER_ID)
def test_no_ask_human_layer_when_unconfigured():
from clients.agent_backend.request_builder import DIFY_ASK_HUMAN_LAYER_ID
request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input())
assert all(layer.name != DIFY_ASK_HUMAN_LAYER_ID for layer in request.composition.layers)
def test_deferred_tool_results_threaded_into_request():
from dify_agent.protocol import DeferredToolResultsPayload
payload = DeferredToolResultsPayload(
calls={
"tool-call-1": {
"status": "submitted",
"action": {"id": "submit", "label": "Submit"},
"values": {"x": "y"},
}
}
)
run_input = _run_input().model_copy(update={"deferred_tool_results": payload})
request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input)
assert request.deferred_tool_results is not None
assert "tool-call-1" in request.deferred_tool_results.calls

View File

@ -0,0 +1,51 @@
from unittest.mock import MagicMock, patch
import httpx
from configs.remote_settings_sources.nacos.http_request import NacosHttpClient
def _ok_response(text: str = "ok", json_data: dict | None = None) -> MagicMock:
response = MagicMock()
response.text = text
response.raise_for_status.return_value = None
if json_data is not None:
response.json.return_value = json_data
return response
def test_http_request_passes_bounded_timeout():
client = NacosHttpClient()
with patch("configs.remote_settings_sources.nacos.http_request.httpx.request") as mock_request:
mock_request.return_value = _ok_response()
client.http_request("/nacos/v1/cs/configs")
timeout = mock_request.call_args.kwargs["timeout"]
assert isinstance(timeout, httpx.Timeout)
assert timeout.read is not None
assert timeout.connect is not None
def test_http_request_returns_graceful_message_on_timeout():
client = NacosHttpClient()
with patch(
"configs.remote_settings_sources.nacos.http_request.httpx.request",
side_effect=httpx.ConnectTimeout("connection timed out"),
):
result = client.http_request("/nacos/v1/cs/configs")
assert "Nacos" in result
assert "timed out" in result.lower()
def test_get_access_token_passes_bounded_timeout():
client = NacosHttpClient()
client.username = "user"
client.password = "pass"
with patch("configs.remote_settings_sources.nacos.http_request.httpx.request") as mock_request:
mock_request.return_value = _ok_response(json_data={"accessToken": "tok", "tokenTtl": 100})
token = client.get_access_token(force_refresh=True)
assert token == "tok"
timeout = mock_request.call_args.kwargs["timeout"]
assert isinstance(timeout, httpx.Timeout)

View File

@ -1,16 +1,18 @@
from inspect import unwrap
from types import SimpleNamespace
from typing import cast
from typing import Any, cast
import pytest
from flask import Flask
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.console import console_ns
from controllers.console.agent import composer as composer_controller
from controllers.console.agent import roster as roster_controller
from controllers.console.agent.composer import (
AgentAppComposerApi,
AgentAppComposerCandidatesApi,
AgentAppComposerValidateApi,
AgentComposerApi,
AgentComposerCandidatesApi,
AgentComposerValidateApi,
WorkflowAgentComposerApi,
WorkflowAgentComposerCandidatesApi,
WorkflowAgentComposerImpactApi,
@ -18,42 +20,24 @@ from controllers.console.agent.composer import (
WorkflowAgentComposerValidateApi,
)
from controllers.console.agent.roster import (
AgentAppApi,
AgentAppListApi,
AgentInviteOptionsApi,
AgentRosterDetailApi,
AgentRosterListApi,
AgentRosterVersionDetailApi,
AgentRosterVersionsApi,
)
from models.model import AppMode
from controllers.console.app import completion as completion_controller
from controllers.console.app import message as message_controller
from controllers.console.app.completion import AgentChatMessageApi, AgentChatMessageStopApi
from controllers.console.app.message import (
AgentChatMessageListApi,
AgentMessageApi,
AgentMessageFeedbackApi,
AgentMessageSuggestedQuestionApi,
)
from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant
def _agent_response(agent_id: str = "agent-1") -> dict:
return {
"id": agent_id,
"name": "Analyst",
"description": "",
"icon_type": None,
"icon": None,
"icon_background": None,
"agent_kind": "dify_agent",
"scope": "roster",
"source": "agent_app",
"app_id": None,
"workflow_id": None,
"workflow_node_id": None,
"active_config_snapshot_id": "version-1",
"active_config_snapshot": _version_response(),
"status": "active",
"created_by": "account-1",
"updated_by": "account-1",
"archived_by": None,
"archived_at": None,
"created_at": None,
"updated_at": None,
}
def _version_response(version_id: str = "version-1") -> dict:
return {
"id": version_id,
@ -103,6 +87,38 @@ def _agent_app_composer_response() -> dict:
}
def _app_detail_obj(**overrides):
data = {
"id": "app-1",
"name": "Iris",
"description": "Agent app",
"mode_compatible_with_agent": "agent",
"icon_type": "emoji",
"icon": "robot",
"icon_background": "#fff",
"enable_site": False,
"enable_api": False,
"app_model_config": None,
"workflow": None,
"tracing": None,
"use_icon_as_answer_icon": False,
"created_by": "account-1",
"created_at": None,
"updated_by": "account-1",
"updated_at": None,
"access_mode": None,
"tags": [],
"api_base_url": None,
"max_active_requests": 0,
"deleted_tools": [],
"site": None,
"bound_agent_id": "00000000-0000-0000-0000-000000000001",
"tenant_id": "tenant-1",
}
data.update(overrides)
return SimpleNamespace(**data)
def _candidates_response(variant: str) -> dict:
return {
"variant": variant,
@ -112,20 +128,43 @@ def _candidates_response(variant: str) -> dict:
}
def _get_app_model_modes(view) -> list[AppMode]:
current = view
while current is not None:
closure = getattr(current, "__closure__", None)
if closure is not None:
for cell in closure:
try:
value = cell.cell_contents
except ValueError:
continue
if isinstance(value, list) and all(isinstance(item, AppMode) for item in value):
return value
current = getattr(current, "__wrapped__", None)
return []
def test_agent_v2_console_routes_are_agent_id_first() -> None:
paths = {route for item in console_ns.resources for route in item.urls}
for route in (
"/agent",
"/agent/<uuid:agent_id>",
"/agent/<uuid:agent_id>/composer",
"/agent/<uuid:agent_id>/composer/validate",
"/agent/<uuid:agent_id>/composer/candidates",
"/agent/<uuid:agent_id>/features",
"/agent/<uuid:agent_id>/referencing-workflows",
"/agent/<uuid:agent_id>/drive/files",
"/agent/<uuid:agent_id>/sandbox/files",
"/agent/<uuid:agent_id>/skills/upload",
"/agent/<uuid:agent_id>/files",
"/agent/<uuid:agent_id>/chat-messages",
"/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop",
"/agent/<uuid:agent_id>/feedbacks",
"/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions",
"/agent/<uuid:agent_id>/messages/<uuid:message_id>",
"/agent/invite-options",
):
assert route in paths
for route in (
"/agents",
"/agents/invite-options",
"/agents/<uuid:agent_id>",
"/agents/<uuid:agent_id>/versions",
"/apps/<uuid:app_id>/agent-composer",
"/apps/<uuid:app_id>/agent-composer/validate",
"/apps/<uuid:app_id>/agent-composer/candidates",
"/apps/<uuid:app_id>/agent-features",
"/apps/<uuid:app_id>/agent-referencing-workflows",
"/apps/<uuid:app_id>/agent-sandbox/files",
):
assert route not in paths
@pytest.fixture
@ -133,26 +172,145 @@ def account_id() -> str:
return "account-1"
def test_roster_list_get_parses_query_and_calls_service(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_app_list_and_create_use_agent_route(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
captured: dict[str, object] = {}
def list_roster_agents(_self: object, **kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False}
class FakeAppService:
def get_app(self, app_obj: object) -> object:
return app_obj
monkeypatch.setattr(roster_controller.AgentRosterService, "list_roster_agents", list_roster_agents)
def get_paginate_apps(self, user_id: str, tenant_id: str, params) -> object:
captured["list"] = {"user_id": user_id, "tenant_id": tenant_id, "params": params}
return SimpleNamespace(
page=1,
per_page=10,
total=1,
has_next=False,
items=[_app_detail_obj(id="app-list", bound_agent_id="agent-list")],
)
with app.test_request_context("/console/api/agents?page=2&limit=5&keyword=analyst"):
result = unwrap(AgentRosterListApi.get)(AgentRosterListApi(), "tenant-1")
def create_app(self, tenant_id: str, params, current_user: object) -> object:
captured["create"] = {"tenant_id": tenant_id, "params": params, "current_user": current_user}
return _app_detail_obj(id="app-created", bound_agent_id="agent-created")
assert result["page"] == 2
assert captured == {"tenant_id": "tenant-1", "page": 2, "limit": 5, "keyword": "analyst"}
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"load_app_backing_agents_by_app_id",
lambda _self, **kwargs: {
"app-list": SimpleNamespace(id="agent-list", role="List role", active_config_snapshot_id=None)
},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_app_backing_agent",
lambda _self, **kwargs: SimpleNamespace(
id="agent-created", role="Created role", active_config_snapshot_id=None
),
)
monkeypatch.setattr(
roster_controller.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
with app.test_request_context("/console/api/agent?page=1&limit=10&mode=workflow"):
listed = unwrap(AgentAppListApi.get)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id))
assert listed["page"] == 1
assert listed["limit"] == 10
assert listed["total"] == 1
assert listed["data"][0]["id"] == "agent-list"
assert listed["data"][0]["app_id"] == "app-list"
assert listed["data"][0]["role"] == "List role"
assert listed["data"][0]["active_config_is_published"] is False
assert "bound_agent_id" not in listed["data"][0]
list_call = cast(dict[str, object], captured["list"])
list_params = cast(Any, list_call["params"])
assert list_params.mode == "agent"
assert list_params.status == "normal"
with app.test_request_context(
"/console/api/agent",
json={"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"},
):
created, status = unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id))
assert status == 201
assert created["id"] == "agent-created"
assert created["app_id"] == "app-created"
assert created["role"] == "Created role"
assert created["active_config_is_published"] is False
assert "bound_agent_id" not in created
create_call = cast(dict[str, object], captured["create"])
create_params = cast(Any, create_call["params"])
assert create_params.mode == "agent"
def test_roster_direct_mutation_endpoints_are_not_exposed() -> None:
assert not hasattr(AgentRosterListApi, "post")
assert not hasattr(AgentRosterDetailApi, "patch")
assert not hasattr(AgentRosterDetailApi, "delete")
def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id)
captured: dict[str, object] = {}
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_agent_app_model",
lambda _self, **kwargs: app_model,
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_app_backing_agent",
lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role", active_config_snapshot_id=None),
)
monkeypatch.setattr(
roster_controller.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
class FakeAppService:
def get_app(self, app_obj: object) -> object:
captured["get_app"] = app_obj
return app_obj
def update_app(self, app_obj: object, args: dict[str, object]) -> object:
captured["update"] = {"app": app_obj, "args": args}
return _app_detail_obj(id="app-1", name=args["name"], bound_agent_id=agent_id)
def delete_app(self, app_obj: object) -> None:
captured["delete"] = app_obj
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
detail = unwrap(AgentAppApi.get)(AgentAppApi(), "tenant-1", agent_id)
assert detail["id"] == agent_id
assert detail["app_id"] == "app-1"
assert detail["role"] == "Resolved role"
assert detail["active_config_is_published"] is False
assert "bound_agent_id" not in detail
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001",
json={"name": "Renamed", "description": "", "icon_type": "emoji", "icon": "R"},
):
updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id)
assert updated["name"] == "Renamed"
assert updated["id"] == agent_id
assert updated["app_id"] == "app-1"
assert updated["role"] == "Resolved role"
assert updated["active_config_is_published"] is False
assert "bound_agent_id" not in updated
update_call = cast(dict[str, object], captured["update"])
assert update_call["app"] is app_model
deleted, status = unwrap(AgentAppApi.delete)(AgentAppApi(), "tenant-1", agent_id)
assert (deleted, status) == ("", 204)
assert captured["delete"] is app_model
def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@ -164,21 +322,16 @@ def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.Monkey
monkeypatch.setattr(roster_controller.AgentRosterService, "list_invite_options", list_invite_options)
with app.test_request_context("/console/api/agents/invite-options?page=1&limit=10&app_id=app-1"):
with app.test_request_context("/console/api/agent/invite-options?page=1&limit=10&app_id=app-1"):
result = unwrap(AgentInviteOptionsApi.get)(AgentInviteOptionsApi(), "tenant-1")
assert result == {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False}
assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"}
def test_roster_detail_and_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
version_id = "00000000-0000-0000-0000-000000000002"
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_roster_agent_detail",
lambda _self, **kwargs: _agent_response(cast(str, kwargs["agent_id"])),
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"list_agent_versions",
@ -207,7 +360,6 @@ def test_roster_detail_and_versions_call_services(app: Flask, monkeypatch: pytes
},
)
assert unwrap(AgentRosterDetailApi.get)(AgentRosterDetailApi(), "tenant-1", agent_id)["id"] == agent_id
assert (
unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), "tenant-1", agent_id)["data"][0]["id"]
== "version-1"
@ -294,59 +446,342 @@ def test_workflow_impact_returns_empty_without_version(app: Flask) -> None:
assert result == {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
def test_agent_app_composer_get_put_validate_and_candidates(
def test_agent_composer_routes_resolve_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
app_model = SimpleNamespace(id="app-1")
agent_id = "00000000-0000-0000-0000-000000000001"
captured: dict[str, object] = {}
payload = {
"variant": ComposerVariant.AGENT_APP.value,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
"agent_soul": {"prompt": {"system_prompt": "x"}},
}
monkeypatch.setattr(composer_controller, "resolve_agent_app_model", lambda **kwargs: SimpleNamespace(id="app-1"))
def load_agent_app_composer(**kwargs: object) -> dict:
captured["load"] = kwargs
return _agent_app_composer_response()
def save_agent_app_composer(**kwargs: object) -> dict:
captured["save"] = kwargs
return _agent_app_composer_response()
def collect_validation_findings(**kwargs: object) -> dict:
captured["validate"] = kwargs
return {"warnings": [], "knowledge_retrieval_placeholder": []}
def get_agent_app_candidates(**kwargs: object) -> dict:
captured["candidates"] = kwargs
return _candidates_response("agent_app")
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_agent_app_composer",
lambda **kwargs: _agent_app_composer_response(),
load_agent_app_composer,
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_agent_app_composer",
lambda **kwargs: _agent_app_composer_response(),
save_agent_app_composer,
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None
composer_controller.AgentComposerService,
"collect_validation_findings",
collect_validation_findings,
)
monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_agent_app_candidates",
lambda **kwargs: _candidates_response("agent_app"),
get_agent_app_candidates,
)
assert unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), "tenant-1", app_model)["variant"] == "agent_app"
assert unwrap(AgentComposerApi.get)(AgentComposerApi(), "tenant-1", agent_id)["variant"] == "agent_app"
assert cast(dict[str, object], captured["load"])["app_id"] == "app-1"
with app.test_request_context(json=payload):
assert (
unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), "tenant-1", account_id, app_model)["variant"]
== "agent_app"
unwrap(AgentComposerApi.put)(AgentComposerApi(), "tenant-1", account_id, agent_id)["variant"] == "agent_app"
)
assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), "tenant-1", app_model) == {
assert cast(dict[str, object], captured["save"])["app_id"] == "app-1"
assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), "tenant-1", agent_id) == {
"result": "success",
"errors": [],
"warnings": [],
"knowledge_retrieval_placeholder": [],
}
agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)(
AgentAppComposerCandidatesApi(), "tenant-1", account_id, app_model
assert cast(dict[str, object], captured["validate"])["agent_id"] == agent_id
candidates = unwrap(AgentComposerCandidatesApi.get)(AgentComposerCandidatesApi(), "tenant-1", account_id, agent_id)
assert candidates["variant"] == "agent_app"
assert cast(dict[str, object], captured["candidates"])["app_id"] == "app-1"
def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = SimpleNamespace(id="app-1", mode="agent")
captured: dict[str, object] = {}
def resolve_agent_app_model(**kwargs: object) -> object:
captured["resolve"] = kwargs
return app_model
def create_chat_message(**kwargs: object) -> dict[str, object]:
captured["create"] = kwargs
return {"result": "generated"}
def stop_chat_message(**kwargs: object) -> tuple[dict[str, object], int]:
captured["stop"] = kwargs
return {"result": "success"}, 200
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)
with app.test_request_context(json={"inputs": {}, "query": "hello"}):
assert unwrap(AgentChatMessageApi.post)(
AgentChatMessageApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id
) == {"result": "generated"}
assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id}
create_call = cast(dict[str, object], captured["create"])
assert create_call["app_model"] is app_model
assert cast(SimpleNamespace, create_call["current_user"]).id == account_id
assert unwrap(AgentChatMessageStopApi.post)(
AgentChatMessageStopApi(), "tenant-1", account_id, agent_id, "task-1"
) == ({"result": "success"}, 200)
stop_call = cast(dict[str, object], captured["stop"])
assert stop_call == {"current_user_id": account_id, "app_model": app_model, "task_id": "task-1"}
def test_agent_chat_helper_forces_agent_streaming_and_external_trace(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
app_model = SimpleNamespace(id="app-1", mode="agent")
current_user = SimpleNamespace(id=account_id)
captured: dict[str, object] = {}
def generate(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return {"answer": "ok"}
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
monkeypatch.setattr(
completion_controller.helper,
"compact_generate_response",
lambda response: {"response": response},
)
assert agent_app_candidates["variant"] == "agent_app"
with app.test_request_context(
json={"inputs": {}, "query": "hello", "response_mode": "streaming"},
headers={"X-Trace-Id": "trace-1"},
):
result = completion_controller._create_chat_message(current_user=current_user, app_model=app_model)
assert result == {"response": {"answer": "ok"}}
assert captured["app_model"] is app_model
assert captured["user"] is current_user
assert captured["streaming"] is True
args = cast(dict[str, object], captured["args"])
assert args["response_mode"] == "streaming"
assert args["auto_generate_name"] is False
assert args["external_trace_id"] == "trace-1"
def test_agent_app_composer_routes_are_agent_mode_only() -> None:
assert _get_app_model_modes(AgentAppComposerApi.get) == [AppMode.AGENT]
assert _get_app_model_modes(AgentAppComposerApi.put) == [AppMode.AGENT]
assert _get_app_model_modes(AgentAppComposerValidateApi.post) == [AppMode.AGENT]
assert _get_app_model_modes(AgentAppComposerCandidatesApi.get) == [AppMode.AGENT]
@pytest.mark.parametrize(
("error", "expected"),
[
(completion_controller.services.errors.conversation.ConversationNotExistsError(), NotFound),
(
completion_controller.services.errors.conversation.ConversationCompletedError(),
completion_controller.ConversationCompletedError,
),
(
completion_controller.services.errors.app_model_config.AppModelConfigBrokenError(),
completion_controller.AppUnavailableError,
),
(
completion_controller.ProviderTokenNotInitError("not initialized"),
completion_controller.ProviderNotInitializeError,
),
(completion_controller.QuotaExceededError(), completion_controller.ProviderQuotaExceededError),
(
completion_controller.ModelCurrentlyNotSupportError(),
completion_controller.ProviderModelCurrentlyNotSupportError,
),
(completion_controller.InvokeRateLimitError("rate limited"), completion_controller.InvokeRateLimitHttpError),
(completion_controller.InvokeError("invoke failed"), completion_controller.CompletionRequestError),
(RuntimeError("unexpected"), InternalServerError),
],
)
def test_agent_chat_helper_maps_generation_errors(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
error: Exception,
expected: type[Exception],
) -> None:
app_model = SimpleNamespace(id="app-1", mode="chat")
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", lambda **_: (_ for _ in ()).throw(error))
with app.test_request_context(json={"inputs": {}, "query": "hello"}):
with pytest.raises(expected):
completion_controller._create_chat_message(
current_user=SimpleNamespace(id="account-1"),
app_model=app_model,
)
def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
message_id = "00000000-0000-0000-0000-000000000002"
app_model = SimpleNamespace(id="app-1")
current_user = SimpleNamespace(id="account-1")
captured: dict[str, object] = {}
def resolve_agent_app_model(**kwargs: object) -> object:
captured["resolve"] = kwargs
return app_model
def list_chat_messages(**kwargs: object) -> dict[str, object]:
captured["list"] = kwargs
return {"data": []}
def update_message_feedback(**kwargs: object) -> dict[str, object]:
captured["feedback"] = kwargs
return {"result": "success"}
def get_message_suggested_questions(**kwargs: object) -> dict[str, object]:
captured["suggested"] = kwargs
return {"data": ["next"]}
def get_message_detail(**kwargs: object) -> dict[str, object]:
captured["detail"] = kwargs
return {"id": message_id}
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)
monkeypatch.setattr(message_controller, "_get_message_detail", get_message_detail)
assert unwrap(AgentChatMessageListApi.get)(AgentChatMessageListApi(), "tenant-1", agent_id) == {"data": []}
assert cast(dict[str, object], captured["list"])["app_model"] is app_model
with app.test_request_context(json={"message_id": message_id, "rating": "like"}):
assert unwrap(AgentMessageFeedbackApi.post)(AgentMessageFeedbackApi(), "tenant-1", current_user, agent_id) == {
"result": "success"
}
feedback_call = cast(dict[str, object], captured["feedback"])
assert feedback_call["app_model"] is app_model
assert feedback_call["current_user"] is current_user
assert unwrap(AgentMessageSuggestedQuestionApi.get)(
AgentMessageSuggestedQuestionApi(), "tenant-1", current_user, agent_id, message_id
) == {"data": ["next"]}
suggested_call = cast(dict[str, object], captured["suggested"])
assert suggested_call["app_model"] is app_model
assert suggested_call["current_user"] is current_user
assert suggested_call["message_id"] == message_id
assert unwrap(AgentMessageApi.get)(AgentMessageApi(), "tenant-1", agent_id, message_id) == {"id": message_id}
detail_call = cast(dict[str, object], captured["detail"])
assert detail_call == {"app_model": app_model, "message_id": message_id}
def test_list_chat_messages_supports_first_id_pagination(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
conversation_id = "00000000-0000-0000-0000-000000000010"
first_message_id = "00000000-0000-0000-0000-000000000011"
older_message_id = "00000000-0000-0000-0000-000000000012"
conversation = SimpleNamespace(id=conversation_id)
first_message = SimpleNamespace(id=first_message_id, created_at=2)
older_message = SimpleNamespace(id=older_message_id, created_at=1)
scalar_values = iter([conversation, first_message, True])
scalars_result = SimpleNamespace(all=lambda: [older_message])
session = SimpleNamespace(
scalar=lambda _stmt: next(scalar_values),
scalars=lambda _stmt: scalars_result,
)
class FakeMessagePaginationResponse:
@classmethod
def model_validate(cls, pagination: object, from_attributes: bool = False) -> object:
return SimpleNamespace(
model_dump=lambda mode: {
"data": [item.id for item in pagination.data],
"limit": pagination.limit,
"has_more": pagination.has_more,
}
)
monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session))
monkeypatch.setattr(message_controller, "attach_message_extra_contents", lambda messages: None)
monkeypatch.setattr(message_controller, "MessageInfiniteScrollPaginationResponse", FakeMessagePaginationResponse)
with app.test_request_context(
"/console/api/agent/agent-1/chat-messages"
f"?conversation_id={conversation_id}&first_id={first_message_id}&limit=1"
):
result = message_controller._list_chat_messages(app_model=SimpleNamespace(id="app-1"))
assert result == {"data": [older_message_id], "limit": 1, "has_more": True}
def test_update_message_feedback_rejects_empty_rating_without_existing_feedback(
app: Flask, monkeypatch: pytest.MonkeyPatch
) -> None:
message_id = "00000000-0000-0000-0000-000000000002"
message = SimpleNamespace(id=message_id, app_id="app-1", admin_feedback=None)
session = SimpleNamespace(scalar=lambda _stmt: message)
monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session))
with app.test_request_context(json={"message_id": message_id, "rating": None}):
with pytest.raises(ValueError, match="rating cannot be None"):
message_controller._update_message_feedback(
current_user=SimpleNamespace(id="account-1"),
app_model=SimpleNamespace(id="app-1"),
)
@pytest.mark.parametrize(
("error", "expected"),
[
(message_controller.MessageNotExistsError(), NotFound),
(message_controller.ConversationNotExistsError(), NotFound),
(
message_controller.ProviderTokenNotInitError("not initialized"),
message_controller.ProviderNotInitializeError,
),
(message_controller.QuotaExceededError(), message_controller.ProviderQuotaExceededError),
(message_controller.ModelCurrentlyNotSupportError(), message_controller.ProviderModelCurrentlyNotSupportError),
(message_controller.InvokeError("invoke failed"), message_controller.CompletionRequestError),
(
message_controller.SuggestedQuestionsAfterAnswerDisabledError(),
message_controller.AppSuggestedQuestionsAfterAnswerDisabledError,
),
(RuntimeError("unexpected"), InternalServerError),
],
)
def test_get_message_suggested_questions_maps_service_errors(
monkeypatch: pytest.MonkeyPatch,
error: Exception,
expected: type[Exception],
) -> None:
monkeypatch.setattr(
message_controller.MessageService,
"get_suggested_questions_after_answer",
lambda **_: (_ for _ in ()).throw(error),
)
with pytest.raises(expected):
message_controller._get_message_suggested_questions(
current_user=SimpleNamespace(id="account-1"),
app_model=SimpleNamespace(id="app-1"),
message_id="00000000-0000-0000-0000-000000000002",
)
def test_dify_tool_candidate_response_keeps_granularity_fields():

View File

@ -119,6 +119,7 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
service = _AgentAppService()
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model())
monkeypatch.setattr(
module,
"query_params_from_request",
@ -129,11 +130,10 @@ def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPat
"request",
SimpleNamespace(get_json=lambda silent=True: {"conversation_id": "conv-1", "path": "report.txt"}),
)
app_model = _app_model()
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", app_model)
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), "tenant-1", app_model)
upload = unwrap(module.AgentAppSandboxUploadResource.post)(object(), "tenant-1", app_model)
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", "agent-1")
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), "tenant-1", "agent-1")
upload = unwrap(module.AgentAppSandboxUploadResource.post)(object(), "tenant-1", "agent-1")
assert listing["path"] == "sub/report.txt"
assert preview["text"] == "hello"
@ -151,11 +151,12 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytes
raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404)
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model())
monkeypatch.setattr(
module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".")
)
assert unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", _app_model()) == (
assert unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", "agent-1") == (
{"code": "no_active_session", "message": "no active session"},
404,
)

View File

@ -15,8 +15,11 @@ from flask import Flask
from controllers.console.app.agent_drive_inspector import (
AgentDriveDownloadApi,
AgentDriveDownloadByAgentApi,
AgentDriveListApi,
AgentDriveListByAgentApi,
AgentDrivePreviewApi,
AgentDrivePreviewByAgentApi,
)
from services.agent_drive_service import AgentDriveError
@ -53,6 +56,32 @@ def test_list_filters_value_pointers_out_of_console_payload():
assert drive.return_value.manifest.call_args.kwargs["prefix"] == "pdf-toolkit/"
def test_list_by_agent_filters_value_pointers_out_of_console_payload():
raw = _raw(AgentDriveListByAgentApi.get)
with app.test_request_context("/?prefix=pdf-toolkit/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.manifest.return_value = [
{
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"hash": "h",
"mime_type": "text/markdown",
"file_kind": "tool_file",
"file_id": "tf-1",
"created_at": 1718000000,
}
]
body = raw(AgentDriveListByAgentApi(), "tenant-1", "agent-1")
assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md"
assert "file_id" not in body["items"][0]
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1"
def test_list_resolves_workflow_node_binding_agent():
raw = _raw(AgentDriveListApi.get)
with app.test_request_context("/?node_id=agent-node-1"):
@ -101,6 +130,37 @@ def test_preview_passes_through_and_maps_errors():
assert body["code"] == "drive_key_not_found"
def test_preview_by_agent_passes_through_and_maps_errors():
raw = _raw(AgentDrivePreviewByAgentApi.get)
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.preview.return_value = {
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"truncated": False,
"binary": False,
"text": "# hi",
}
body = raw(AgentDrivePreviewByAgentApi(), "tenant-1", "agent-1")
assert body["text"] == "# hi"
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
with app.test_request_context("/?key=ghost/SKILL.md"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP),
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.preview.side_effect = AgentDriveError(
"drive_key_not_found", "no drive entry", status_code=404
)
body, status = raw(AgentDrivePreviewByAgentApi(), "tenant-1", "agent-1")
assert status == 404
assert body["code"] == "drive_key_not_found"
def test_download_returns_signed_url_json():
raw = _raw(AgentDriveDownloadApi.get)
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
@ -108,3 +168,16 @@ def test_download_returns_signed_url_json():
drive.return_value.download_url.return_value = "https://signed.example/zip"
body = raw(AgentDriveDownloadApi(), _APP)
assert body == {"url": "https://signed.example/zip"}
def test_download_by_agent_returns_signed_url_json():
raw = _raw(AgentDriveDownloadByAgentApi.get)
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.download_url.return_value = "https://signed.example/zip"
body = raw(AgentDriveDownloadByAgentApi(), "tenant-1", "agent-1")
assert body == {"url": "https://signed.example/zip"}
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")

View File

@ -14,7 +14,16 @@ from unittest.mock import MagicMock, patch
from flask import Flask
from controllers.console.app.agent import AgentSkillStandardizeApi, AgentSkillUploadApi
from controllers.console.app.agent import (
AgentDriveFilesByAgentApi,
AgentSkillByAgentApi,
AgentSkillInferToolsByAgentApi,
AgentSkillStandardizeApi,
AgentSkillStandardizeByAgentApi,
AgentSkillUploadApi,
AgentSkillUploadByAgentApi,
)
from models.model import AppMode
from services.agent.skill_package_service import SkillPackageError
from services.agent_drive_service import AgentDriveError
@ -32,7 +41,8 @@ def _file_ctx(*, files: dict[str, bytes] | None = None):
_USER = SimpleNamespace(id="user-1")
_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1")
_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bound_agent_id="agent-1")
_WORKFLOW_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW, bound_agent_id=None)
def test_upload_validates_and_returns_skill_ref():
@ -56,6 +66,28 @@ def test_upload_validates_and_returns_skill_ref():
manifest.to_skill_ref.assert_called_once_with(file_id="uf-1")
def test_upload_by_agent_resolves_app_and_returns_skill_ref():
raw = _raw(AgentSkillUploadByAgentApi.post)
manifest = MagicMock()
manifest.to_skill_ref.return_value.model_dump.return_value = {"name": "S", "file_id": "uf-1"}
manifest.model_dump.return_value = {"name": "S"}
with _file_ctx(files={"file": b"zip-bytes"}):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.SkillPackageService") as pkg,
patch(f"{_MOD}.FileService") as fs,
patch(f"{_MOD}.db"),
):
pkg.return_value.validate_and_extract.return_value = manifest
fs.return_value.upload_file.return_value = SimpleNamespace(id="uf-1")
body, status = raw(AgentSkillUploadByAgentApi(), "tenant-1", _USER, "agent-1")
assert status == 201
assert body["skill"] == {"name": "S", "file_id": "uf-1"}
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
def test_upload_no_file_is_400():
raw = _raw(AgentSkillUploadApi.post)
with _file_ctx(files={}):
@ -87,9 +119,25 @@ def test_standardize_returns_result():
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1"
def test_standardize_by_agent_resolves_app():
raw = _raw(AgentSkillStandardizeByAgentApi.post)
with _file_ctx(files={"file": b"zip"}):
with (
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": "s"}, "manifest": {}}
body, status = raw(AgentSkillStandardizeByAgentApi(), "tenant-1", _USER, "agent-1")
assert status == 201
assert body["skill"] == {"path": "s"}
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1"
def test_standardize_no_bound_agent_is_400():
raw = _raw(AgentSkillStandardizeApi.post)
app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bound_agent_id=None)
with _file_ctx(files={"file": b"zip"}):
body, status = raw(AgentSkillStandardizeApi(), _USER, app_without_agent)
assert status == 400
@ -98,7 +146,6 @@ def test_standardize_no_bound_agent_is_400():
def test_standardize_resolves_workflow_node_agent():
raw = _raw(AgentSkillStandardizeApi.post)
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with app.test_request_context(
"/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")}
):
@ -108,7 +155,7 @@ def test_standardize_resolves_workflow_node_agent():
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}}
body, status = raw(AgentSkillStandardizeApi(), _USER, workflow_app)
body, status = raw(AgentSkillStandardizeApi(), _USER, _WORKFLOW_APP)
assert status == 201
assert body["skill"] == {"path": "s"}
@ -165,6 +212,31 @@ def test_files_commit_validates_upload_and_returns_drive_ref():
assert composer.add_drive_file_ref.call_args.kwargs["app_id"] == "app-1"
def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id():
raw = _raw(AgentDriveFilesByAgentApi.post)
upload = SimpleNamespace(id="uf-1", name="sample.pdf")
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.console_ns") as ns,
patch(f"{_MOD}.db") as db_mock,
patch(f"{_MOD}.AgentDriveService") as drive,
patch(f"{_MOD}.AgentComposerService") as composer,
):
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
db_mock.session.scalar.return_value = upload
drive.return_value.commit.return_value = [
{"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
]
composer.add_drive_file_ref.return_value = "ver-2"
body, status = raw(AgentDriveFilesByAgentApi(), "tenant-1", _USER, "agent-1")
assert status == 201
assert body["config_version_id"] == "ver-2"
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert composer.add_drive_file_ref.call_args.kwargs["node_id"] is None
def test_files_commit_404_when_upload_not_in_tenant():
from controllers.console.app.agent import AgentDriveFilesApi
@ -186,7 +258,6 @@ def test_files_commit_resolves_workflow_node_agent():
raw = _raw(AgentDriveFilesApi.post)
upload = SimpleNamespace(id="uf-1", name="sample.pdf")
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=agent-node-1"):
with (
patch(f"{_MOD}.console_ns") as ns,
@ -201,7 +272,7 @@ def test_files_commit_resolves_workflow_node_agent():
{"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
]
composer.add_drive_file_ref.return_value = "ver-2"
body, status = raw(AgentDriveFilesApi(), _USER, workflow_app)
body, status = raw(AgentDriveFilesApi(), _USER, _WORKFLOW_APP)
assert status == 201
assert body["config_version_id"] == "ver-2"
@ -229,11 +300,27 @@ def test_files_delete_updates_soul_then_drive():
assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1"
def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id():
raw = _raw(AgentDriveFilesByAgentApi.delete)
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentDriveService") as drive,
):
composer.remove_drive_refs.return_value = "ver-2"
drive.return_value.delete.return_value = ["files/sample.pdf"]
body = raw(AgentDriveFilesByAgentApi(), "tenant-1", _USER, "agent-1")
assert body["config_version_id"] == "ver-2"
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert composer.remove_drive_refs.call_args.kwargs["node_id"] is None
def test_files_delete_resolves_workflow_node_agent():
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.delete)
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=agent-node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
@ -242,7 +329,7 @@ def test_files_delete_resolves_workflow_node_agent():
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
composer.remove_drive_refs.return_value = "ver-2"
drive.return_value.delete.return_value = ["files/sample.pdf"]
body = raw(AgentDriveFilesApi(), _USER, workflow_app)
body = raw(AgentDriveFilesApi(), _USER, _WORKFLOW_APP)
assert body["config_version_id"] == "ver-2"
assert drive.return_value.delete.call_args.kwargs["agent_id"] == "wf-agent-1"
@ -284,6 +371,23 @@ def test_skill_delete_uses_slug_prefix_and_is_idempotent():
assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1"
def test_skill_delete_by_agent_uses_agent_route():
raw = _raw(AgentSkillByAgentApi.delete)
with _json_ctx(method="DELETE", query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentDriveService") as drive,
):
composer.remove_drive_refs.return_value = "ver-2"
drive.return_value.delete.return_value = ["tender-analyzer/SKILL.md"]
body = raw(AgentSkillByAgentApi(), "tenant-1", _USER, "agent-1", "tender-analyzer")
assert body["config_version_id"] == "ver-2"
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert composer.remove_drive_refs.call_args.kwargs["node_id"] is None
def test_skill_delete_rejects_path_like_slug():
from controllers.console.app.agent import AgentSkillApi
@ -314,11 +418,25 @@ def test_infer_tools_returns_draft_suggestions():
assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe"
def test_infer_tools_by_agent_uses_agent_route():
raw = _raw(AgentSkillInferToolsByAgentApi.post)
with _json_ctx(query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.SkillToolInferenceService") as svc,
):
svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None}
body = raw(AgentSkillInferToolsByAgentApi(), "tenant-1", "agent-1", "audio-transcribe")
assert body["inferable"] is True
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert svc.return_value.infer.call_args.kwargs["agent_id"] == "agent-1"
def test_infer_tools_resolves_workflow_node_agent():
from controllers.console.app.agent import AgentSkillInferToolsApi
raw = _raw(AgentSkillInferToolsApi.post)
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with _json_ctx(query_string="node_id=agent-node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
@ -326,7 +444,7 @@ def test_infer_tools_resolves_workflow_node_agent():
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
svc.return_value.infer.return_value = {"inferable": False, "cli_tools": [], "reason": "none"}
body = raw(AgentSkillInferToolsApi(), workflow_app, "audio-transcribe")
body = raw(AgentSkillInferToolsApi(), _WORKFLOW_APP, "audio-transcribe")
assert body["inferable"] is False
assert svc.return_value.infer.call_args.kwargs["agent_id"] == "wf-agent-1"
@ -355,7 +473,7 @@ def test_infer_tools_rejects_path_like_slug_and_unbound_app():
body, status = raw(AgentSkillInferToolsApi(), _APP, "a/b")
assert (status, body["code"]) == (400, "drive_key_invalid")
app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bound_agent_id=None)
with _json_ctx():
body, status = raw(AgentSkillInferToolsApi(), app_without_agent, "x")
assert (status, body["code"]) == (400, "agent_not_bound")

View File

@ -314,37 +314,19 @@ def test_app_list_query_rejects_flat_tag_ids(app_module):
app_module.AppListQuery.model_validate(normalized)
def test_create_agent_app_response_includes_bound_agent_id(app_module, monkeypatch: pytest.MonkeyPatch):
def test_create_app_endpoint_rejects_agent_mode(app_module, monkeypatch: pytest.MonkeyPatch):
payload = {"name": "Iris", "mode": "agent", "description": "Agent app"}
app_obj = SimpleNamespace(
id="app-1",
name="Iris",
description="Agent app",
mode_compatible_with_agent="agent",
icon_type="emoji",
icon="robot",
icon_background="#fff",
enable_site=False,
enable_api=False,
created_at=_ts(),
updated_at=_ts(),
bound_agent_id="agent-1",
)
app_service = MagicMock()
app_service.create_app.return_value = app_obj
monkeypatch.setattr(app_module, "AppService", lambda: app_service)
app_module.console_ns.payload = payload
try:
response, status = _unwrap(app_module.AppListApi().post)("tenant-1", SimpleNamespace(id="account-1"))
with pytest.raises(ValidationError):
_unwrap(app_module.AppListApi().post)("tenant-1", SimpleNamespace(id="account-1"))
finally:
app_module.console_ns.payload = None
assert status == 201
assert response["id"] == "app-1"
assert response["bound_agent_id"] == "agent-1"
created_params = app_service.create_app.call_args.args[1]
assert created_params.mode == "agent"
app_service.create_app.assert_not_called()
def test_app_partial_serialization_uses_aliases(app_models):

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