mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 04:51:11 +08:00
feat(agent-v2): sync nightly updates to main (2026-06-22) (#37651)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
parent
56b0b57ff7
commit
f4fdbeba76
21
.github/workflows/build-push.yml
vendored
21
.github/workflows/build-push.yml
vendored
@ -21,6 +21,7 @@ env:
|
||||
DIFY_WEB_IMAGE_NAME: ${{ vars.DIFY_WEB_IMAGE_NAME || 'langgenius/dify-web' }}
|
||||
DIFY_API_IMAGE_NAME: ${{ vars.DIFY_API_IMAGE_NAME || 'langgenius/dify-api' }}
|
||||
DIFY_AGENT_IMAGE_NAME: ${{ vars.DIFY_AGENT_IMAGE_NAME || 'langgenius/dify-agent-backend' }}
|
||||
DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME: ${{ vars.DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME || 'langgenius/dify-agent-local-sandbox' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -74,6 +75,20 @@ jobs:
|
||||
file: "dify-agent/Dockerfile"
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-agent-local-sandbox-amd64"
|
||||
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
|
||||
artifact_context: "local-sandbox"
|
||||
build_context: "{{defaultContext}}:dify-agent"
|
||||
file: "docker/local-sandbox/Dockerfile"
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-agent-local-sandbox-arm64"
|
||||
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
|
||||
artifact_context: "local-sandbox"
|
||||
build_context: "{{defaultContext}}:dify-agent"
|
||||
file: "docker/local-sandbox/Dockerfile"
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
|
||||
steps:
|
||||
- name: Prepare
|
||||
@ -139,6 +154,9 @@ jobs:
|
||||
- service_name: "validate-agent-amd64"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "dify-agent/Dockerfile"
|
||||
- service_name: "validate-agent-local-sandbox-amd64"
|
||||
build_context: "{{defaultContext}}:dify-agent"
|
||||
file: "docker/local-sandbox/Dockerfile"
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
@ -167,6 +185,9 @@ jobs:
|
||||
- service_name: "merge-agent-images"
|
||||
image_name_env: "DIFY_AGENT_IMAGE_NAME"
|
||||
context: "agent"
|
||||
- service_name: "merge-agent-local-sandbox-images"
|
||||
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
|
||||
context: "local-sandbox"
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@ -78,6 +78,13 @@ def _filter_snapshot_to_specs(
|
||||
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
|
||||
|
||||
|
||||
def _shell_layer_deps(*, include_drive: bool) -> dict[str, str]:
|
||||
deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
if include_drive:
|
||||
deps["drive"] = DIFY_DRIVE_LAYER_ID
|
||||
return deps
|
||||
|
||||
|
||||
class AgentBackendModelConfig(BaseModel):
|
||||
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
|
||||
|
||||
@ -263,6 +270,7 @@ class AgentBackendRunRequestBuilder:
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
@ -329,14 +337,15 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
|
||||
# the agent server can mint per-command Agent Stub env (back proxy);
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context
|
||||
# so the agent server can mint per-command Agent Stub env, and on
|
||||
# drive when present so that env points at /mnt/drive/<drive_ref>.
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
@ -460,6 +469,7 @@ class AgentBackendRunRequestBuilder:
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
@ -528,14 +538,15 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
|
||||
# the agent server can mint per-command Agent Stub env (back proxy);
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context
|
||||
# so the agent server can mint per-command Agent Stub env, and on
|
||||
# drive when present so that env points at /mnt/drive/<drive_ref>.
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@ -30,7 +29,6 @@ from fields.base import ResponseModel
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig
|
||||
from models.model import App, AppMode, UploadFile
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.skill_package_service import SkillManifest, SkillPackageError
|
||||
@ -49,8 +47,6 @@ from services.agent_drive_service import (
|
||||
)
|
||||
from services.agent_service import AgentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
_AGENT_SKILL_UPLOAD_PARAMS = {
|
||||
"file": {
|
||||
@ -130,8 +126,16 @@ class AgentLogResponse(ResponseModel):
|
||||
files: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentUploadedSkillResponse(ResponseModel):
|
||||
name: str
|
||||
description: str
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
|
||||
|
||||
class AgentSkillUploadResponse(ResponseModel):
|
||||
skill: AgentSkillRefConfig
|
||||
skill: AgentUploadedSkillResponse
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
@ -145,13 +149,11 @@ class AgentDriveFileResponse(ResponseModel):
|
||||
|
||||
class AgentDriveFileCommitResponse(ResponseModel):
|
||||
file: AgentDriveFileResponse
|
||||
config_version_id: str | None = None
|
||||
|
||||
|
||||
class AgentDriveDeleteResponse(ResponseModel):
|
||||
result: str
|
||||
removed_keys: list[str] = Field(default_factory=list)
|
||||
config_version_id: str | None = None
|
||||
|
||||
|
||||
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
|
||||
@ -161,6 +163,7 @@ register_response_schema_models(
|
||||
AgentDriveFileCommitResponse,
|
||||
AgentDriveFileResponse,
|
||||
AgentLogResponse,
|
||||
AgentUploadedSkillResponse,
|
||||
AgentSkillUploadResponse,
|
||||
SkillToolInferenceResult,
|
||||
)
|
||||
@ -242,24 +245,6 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
|
||||
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,
|
||||
@ -268,7 +253,6 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
|
||||
"size": row.get("size"),
|
||||
"mime_type": row.get("mime_type"),
|
||||
},
|
||||
"config_version_id": config_version_id,
|
||||
}, 201
|
||||
|
||||
|
||||
@ -283,24 +267,17 @@ def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
|
||||
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)
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[DriveCommitItem(key=key, file_ref=None)],
|
||||
)
|
||||
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}
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True):
|
||||
@ -312,22 +289,20 @@ def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, a
|
||||
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}/")
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
|
||||
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
|
||||
],
|
||||
)
|
||||
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}
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _infer_skill_tools_for_app(*, app_model: App, slug: str):
|
||||
@ -455,7 +430,7 @@ class AgentDriveFilesApi(Resource):
|
||||
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)")
|
||||
@console_ns.doc(description="Delete one drive file by key via drive commit-null semantics")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@ -486,9 +461,7 @@ class AgentSkillByAgentApi(Resource):
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
|
||||
class AgentSkillApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill")
|
||||
@console_ns.doc(
|
||||
description="Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)"
|
||||
)
|
||||
@console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
"""Inner API endpoint for tenant-scoped knowledge retrieval.
|
||||
"""Plugin inner API endpoint for tenant-scoped knowledge retrieval.
|
||||
|
||||
This controller is a thin HTTP wrapper around
|
||||
``services.knowledge_retrieval_inner_service.InnerKnowledgeRetrievalService``.
|
||||
It intentionally keeps authorization simple: shared inner API key plus
|
||||
tenant-scoped app/dataset validation in the service layer.
|
||||
It uses the plugin inner API key because dify-agent calls this endpoint through
|
||||
the same trusted Dify API bridge as other agent/plugin inner calls; tenant-scoped
|
||||
app/dataset validation remains in the service layer.
|
||||
"""
|
||||
|
||||
from flask_restx import Resource
|
||||
@ -11,7 +12,7 @@ from pydantic import ValidationError
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import inner_api_only
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from core.workflow.nodes.knowledge_retrieval import exc as retrieval_exc
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest, InnerKnowledgeRetrieveResponse
|
||||
@ -48,7 +49,7 @@ register_response_schema_models(inner_api_ns, InnerKnowledgeRetrieveResponse)
|
||||
class InnerKnowledgeRetrieveApi(Resource):
|
||||
"""Retrieve knowledge from one or more datasets within the caller tenant."""
|
||||
|
||||
@inner_api_only
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("inner_knowledge_retrieve")
|
||||
@inner_api_ns.doc(description="Retrieve knowledge for trusted internal callers")
|
||||
@inner_api_ns.expect(inner_api_ns.models[InnerKnowledgeRetrieveRequest.__name__])
|
||||
@ -60,9 +61,8 @@ class InnerKnowledgeRetrieveApi(Resource):
|
||||
@inner_api_ns.doc(
|
||||
responses={
|
||||
400: "Invalid request body",
|
||||
401: "Unauthorized - invalid inner API key",
|
||||
403: "Caller tenant does not own the requested resource",
|
||||
404: "App or dataset not found",
|
||||
404: "Invalid plugin inner API key, app not found, or dataset not found",
|
||||
422: "Invalid retrieval configuration",
|
||||
429: "Knowledge retrieval rate limited",
|
||||
502: "External knowledge retrieval failed",
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
"""Inner API for the agent drive (agent 网盘) control plane — ENG-591.
|
||||
"""Inner API for the agent drive (agent 网盘) control plane.
|
||||
|
||||
Two endpoints, called by the dify-agent server (not the sandbox) with the inner
|
||||
API key. The drive ref is the URL segment ``agent-<agent_id>``; the path-like
|
||||
file key travels in the query/body, never as a URL path segment (so its ``/``
|
||||
characters do not collide with routing). Drive-owned semantics: tenant scoped,
|
||||
no user-level FileAccessScope.
|
||||
These endpoints are called by the dify-agent server (not the sandbox) with the
|
||||
inner API key. The drive ref is the URL segment ``agent-<agent_id>``; the
|
||||
path-like file key travels in the query/body, never as a URL path segment (so
|
||||
its ``/`` characters do not collide with routing). Drive-owned semantics:
|
||||
tenant scoped, no user-level FileAccessScope. Commit still canonicalizes the
|
||||
trusted execution-context user through the same EndUser lookup as plugin file
|
||||
upload before validating ToolFile ownership.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
@ -13,6 +15,7 @@ from pydantic import BaseModel, ValidationError
|
||||
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.plugin.wraps import get_user
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
@ -56,6 +59,24 @@ class AgentDriveManifestApi(Resource):
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/skills")
|
||||
class AgentDriveSkillsApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("agent_drive_skills")
|
||||
@inner_api_ns.doc(description="List the skill catalog of an agent drive")
|
||||
def get(self, drive_ref: str):
|
||||
try:
|
||||
agent_id = parse_agent_drive_ref(drive_ref)
|
||||
tenant_id = (request.args.get("tenant_id") or "").strip()
|
||||
if not tenant_id:
|
||||
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/commit")
|
||||
class AgentDriveCommitApi(Resource):
|
||||
@setup_required
|
||||
@ -69,9 +90,10 @@ class AgentDriveCommitApi(Resource):
|
||||
body = _CommitRequest.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as exc:
|
||||
raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc
|
||||
user = get_user(body.tenant_id, body.user_id)
|
||||
items = AgentDriveService().commit(
|
||||
tenant_id=body.tenant_id,
|
||||
user_id=body.user_id,
|
||||
user_id=user.id,
|
||||
agent_id=agent_id,
|
||||
items=body.items,
|
||||
)
|
||||
|
||||
@ -37,6 +37,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_aware_soul_mention_resolver,
|
||||
build_drive_layer_config,
|
||||
build_knowledge_layer_config,
|
||||
build_shell_layer_config,
|
||||
@ -123,9 +124,19 @@ class AgentAppRuntimeRequestBuilder:
|
||||
}
|
||||
|
||||
drive_config = None
|
||||
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
|
||||
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
|
||||
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id)
|
||||
drive_config, drive_warnings = build_drive_layer_config(
|
||||
agent_soul,
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent_id,
|
||||
)
|
||||
append_runtime_warnings(metadata, drive_warnings)
|
||||
soul_prompt_resolver = build_drive_aware_soul_mention_resolver(
|
||||
agent_soul,
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent_id,
|
||||
)
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
|
||||
request = self._request_builder.build_for_agent_app(
|
||||
@ -154,9 +165,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
),
|
||||
# ENG-616: expand slash-menu mention tokens to canonical names so
|
||||
# no frontend-internal {{#…#}} marker ever reaches the model.
|
||||
agent_soul_prompt=expand_prompt_mentions(
|
||||
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
|
||||
).strip()
|
||||
agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||
or None,
|
||||
user_prompt=context.user_query,
|
||||
tools=tools_layer,
|
||||
|
||||
@ -16,9 +16,6 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
|
||||
"knowledge",
|
||||
"env",
|
||||
"sandbox",
|
||||
# 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",
|
||||
@ -32,11 +29,7 @@ RESERVED_AGENT_BACKEND_FEATURES = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def build_runtime_feature_manifest(
|
||||
agent_soul: AgentSoulConfig,
|
||||
*,
|
||||
drive_manifest_enabled: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]:
|
||||
"""Describe PRD capabilities supported by or still reserved from Agent backend runtime."""
|
||||
warnings: list[dict[str, str]] = []
|
||||
soul_dump = agent_soul.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
|
||||
@ -54,38 +47,10 @@ def build_runtime_feature_manifest(
|
||||
}
|
||||
)
|
||||
|
||||
has_skills_files = bool(agent_soul.skills_files.skills or agent_soul.skills_files.files)
|
||||
if has_skills_files and not drive_manifest_enabled:
|
||||
warnings.append(
|
||||
{
|
||||
"section": "agent_soul.skills_files",
|
||||
"code": "drive_manifest_disabled",
|
||||
"message": (
|
||||
"skills_files is configured but AGENT_DRIVE_MANIFEST_ENABLED is off; "
|
||||
"the drive declaration layer is not injected into this run."
|
||||
),
|
||||
}
|
||||
)
|
||||
for skill in agent_soul.skills_files.skills:
|
||||
if not skill.skill_md_key:
|
||||
warnings.append(
|
||||
{
|
||||
"section": "agent_soul.skills_files",
|
||||
"code": "skill_ref_dangling",
|
||||
"message": (
|
||||
f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; "
|
||||
"re-standardize it to expose it at runtime."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
|
||||
reserved_status["knowledge"] = (
|
||||
"supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured"
|
||||
)
|
||||
reserved_status["skills_files"] = (
|
||||
"supported_by_drive_manifest" if drive_manifest_enabled else "drive_manifest_disabled"
|
||||
)
|
||||
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
|
||||
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
|
||||
reserved_status["env"] = "supported_by_shell_bootstrap"
|
||||
|
||||
@ -7,7 +7,6 @@ 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,
|
||||
DifyDriveSkillConfig,
|
||||
)
|
||||
@ -55,10 +54,13 @@ from models.agent_config_entities import (
|
||||
)
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.agent.prompt_mentions import (
|
||||
MentionKind,
|
||||
build_node_job_mention_resolver,
|
||||
build_soul_mention_resolver,
|
||||
expand_prompt_mentions,
|
||||
parse_prompt_mentions,
|
||||
)
|
||||
from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref
|
||||
|
||||
from .output_failure_orchestrator import retry_idempotency_key
|
||||
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
|
||||
@ -153,9 +155,6 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
|
||||
or "Run this workflow Agent Node for the current run."
|
||||
)
|
||||
soul_prompt = expand_prompt_mentions(
|
||||
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
|
||||
).strip()
|
||||
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
|
||||
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
|
||||
try:
|
||||
@ -182,9 +181,20 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
}
|
||||
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
|
||||
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
|
||||
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent.id)
|
||||
drive_config, drive_warnings = build_drive_layer_config(
|
||||
agent_soul,
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent.id,
|
||||
)
|
||||
append_runtime_warnings(metadata, drive_warnings)
|
||||
soul_prompt_resolver = build_drive_aware_soul_mention_resolver(
|
||||
agent_soul,
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent.id,
|
||||
)
|
||||
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
|
||||
request = self._request_builder.build_for_workflow_node(
|
||||
@ -292,10 +302,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
"agent_config_snapshot_id": context.snapshot.id,
|
||||
"binding_id": context.binding.id,
|
||||
"workflow_node_job_mode": node_job.mode.value,
|
||||
"runtime_support": build_runtime_feature_manifest(
|
||||
agent_soul,
|
||||
drive_manifest_enabled=dify_config.AGENT_DRIVE_MANIFEST_ENABLED,
|
||||
),
|
||||
"runtime_support": build_runtime_feature_manifest(agent_soul),
|
||||
}
|
||||
|
||||
def _build_workflow_context_prompt(
|
||||
@ -603,76 +610,107 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s
|
||||
existing.extend(warnings)
|
||||
|
||||
|
||||
def build_drive_aware_soul_mention_resolver(
|
||||
agent_soul: AgentSoulConfig,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
):
|
||||
"""Resolve skill/file mentions against the agent drive and everything else via Agent Soul."""
|
||||
|
||||
base_resolver = build_soul_mention_resolver(agent_soul)
|
||||
drive_service = AgentDriveService()
|
||||
skill_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
skill_names_by_key = {skill["skill_md_key"]: skill["name"] for skill in skill_catalog}
|
||||
drive_keys = {item["key"] for item in drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)}
|
||||
|
||||
def _resolve(mention: object) -> str | None:
|
||||
if not hasattr(mention, "kind") or not hasattr(mention, "ref_id"):
|
||||
return None
|
||||
kind = cast(MentionKind, mention.kind)
|
||||
ref_id = cast(str, mention.ref_id)
|
||||
label = cast(str | None, getattr(mention, "label", None))
|
||||
if kind == MentionKind.SKILL:
|
||||
decoded_key = decode_drive_mention_ref(ref_id)
|
||||
return skill_names_by_key.get(decoded_key) or label or decoded_key
|
||||
if kind == MentionKind.FILE:
|
||||
decoded_key = decode_drive_mention_ref(ref_id)
|
||||
if decoded_key in drive_keys:
|
||||
return decoded_key.rsplit("/", 1)[-1]
|
||||
return label or decoded_key
|
||||
return base_resolver(cast(Any, mention))
|
||||
|
||||
return _resolve
|
||||
|
||||
|
||||
def build_drive_layer_config(
|
||||
agent_soul: AgentSoulConfig,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str | None,
|
||||
) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]:
|
||||
"""Catalog the soul's drive-backed Skills & Files into the dify.drive declaration.
|
||||
"""Derive drive runtime catalog + prompt-mentioned eager-pull keys from the drive."""
|
||||
|
||||
Returns ``(config, warnings)`` — ``config is None`` means nothing to inject
|
||||
(no skills/files configured, or no agent identity to address the drive by).
|
||||
Refs that predate standardization (no drive key) are skipped with a warning
|
||||
instead of failing the run, so historic souls keep running.
|
||||
"""
|
||||
skill_refs = agent_soul.skills_files.skills
|
||||
file_refs = agent_soul.skills_files.files
|
||||
if not skill_refs and not file_refs:
|
||||
return None, []
|
||||
|
||||
warnings: list[dict[str, str]] = []
|
||||
mentioned_drive_refs = [
|
||||
decode_drive_mention_ref(mention.ref_id)
|
||||
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt)
|
||||
if mention.kind in {MentionKind.SKILL, MentionKind.FILE}
|
||||
]
|
||||
ordered_mentions = list(dict.fromkeys(ref for ref in mentioned_drive_refs if ref))
|
||||
if not agent_id:
|
||||
if not ordered_mentions:
|
||||
return None, []
|
||||
return None, [
|
||||
{
|
||||
"section": "agent_soul.prompt.system_prompt",
|
||||
"code": "drive_ref_dangling",
|
||||
"message": "drive mentions are configured but the run has no bound agent to address a drive by.",
|
||||
}
|
||||
]
|
||||
|
||||
drive_service = AgentDriveService()
|
||||
skills_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
manifest_items = drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)
|
||||
manifest_by_key = {item["key"]: item for item in manifest_items}
|
||||
skill_keys = {skill["skill_md_key"] for skill in skills_catalog}
|
||||
warnings: list[dict[str, str]] = []
|
||||
mentioned_skill_keys: list[str] = []
|
||||
mentioned_file_keys: list[str] = []
|
||||
for drive_key in ordered_mentions:
|
||||
if drive_key in skill_keys:
|
||||
mentioned_skill_keys.append(drive_key)
|
||||
continue
|
||||
if drive_key in manifest_by_key:
|
||||
mentioned_file_keys.append(drive_key)
|
||||
continue
|
||||
warnings.append(
|
||||
{
|
||||
"section": "agent_soul.skills_files",
|
||||
"code": "skill_ref_dangling",
|
||||
"message": "skills_files is configured but the run has no bound agent to address a drive by.",
|
||||
"section": "agent_soul.prompt.system_prompt",
|
||||
"code": "mention_target_missing",
|
||||
"message": f"drive mention '{drive_key}' has no matching drive entry.",
|
||||
}
|
||||
)
|
||||
return None, warnings
|
||||
|
||||
skills: list[DifyDriveSkillConfig] = []
|
||||
for skill in skill_refs:
|
||||
if not skill.skill_md_key:
|
||||
warnings.append(
|
||||
{
|
||||
"section": "agent_soul.skills_files",
|
||||
"code": "skill_ref_dangling",
|
||||
"message": (
|
||||
f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; "
|
||||
"re-standardize it to expose it at runtime."
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
skills.append(
|
||||
DifyDriveSkillConfig(
|
||||
name=skill.name or skill.skill_md_key.split("/", 1)[0],
|
||||
description=skill.description or "",
|
||||
skill_md_key=skill.skill_md_key,
|
||||
archive_key=skill.full_archive_key,
|
||||
)
|
||||
skills = [
|
||||
DifyDriveSkillConfig(
|
||||
path=skill["path"],
|
||||
name=skill["name"],
|
||||
description=skill["description"],
|
||||
skill_md_key=skill["skill_md_key"],
|
||||
archive_key=skill["archive_key"],
|
||||
)
|
||||
for skill in skills_catalog
|
||||
]
|
||||
|
||||
files: list[DifyDriveFileConfig] = []
|
||||
for file in file_refs:
|
||||
if not file.drive_key:
|
||||
# Plain upload references (pre-ENG-625) are not drive-backed; they are
|
||||
# simply invisible to the manifest rather than a defect worth warning on.
|
||||
continue
|
||||
size = file.get("size")
|
||||
files.append(
|
||||
DifyDriveFileConfig(
|
||||
name=file.name or file.drive_key.rsplit("/", 1)[-1],
|
||||
key=file.drive_key,
|
||||
size=size if isinstance(size, int) else None,
|
||||
mime_type=file.type,
|
||||
)
|
||||
)
|
||||
|
||||
if not skills and not files:
|
||||
return None, warnings
|
||||
return DifyDriveLayerConfig(drive_ref=f"agent-{agent_id}", skills=skills, files=files), warnings
|
||||
return (
|
||||
DifyDriveLayerConfig(
|
||||
drive_ref=f"agent-{agent_id}",
|
||||
skills=skills,
|
||||
mentioned_skill_keys=mentioned_skill_keys,
|
||||
mentioned_file_keys=mentioned_file_keys,
|
||||
),
|
||||
warnings,
|
||||
)
|
||||
|
||||
|
||||
def _cli_tool_enabled(item: object) -> bool:
|
||||
|
||||
@ -35,7 +35,6 @@ class WorkflowAgentNodeValidator:
|
||||
"soul",
|
||||
"prompt",
|
||||
"system_prompt",
|
||||
"skills_files",
|
||||
"skills",
|
||||
"files",
|
||||
"tools",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
@ -16,10 +16,8 @@ from models.agent import (
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentCliToolConfig,
|
||||
AgentFileRefConfig,
|
||||
AgentHumanContactConfig,
|
||||
AgentKnowledgeDatasetConfig,
|
||||
AgentSkillRefConfig,
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
DeclaredOutputType,
|
||||
@ -396,20 +394,6 @@ class AgentComposerDifyToolCandidateResponse(ResponseModel):
|
||||
tools_count: int | None = None
|
||||
|
||||
|
||||
class AgentComposerSkillCandidateResponse(AgentSkillRefConfig):
|
||||
kind: Literal["skill"] = "skill"
|
||||
|
||||
|
||||
class AgentComposerFileCandidateResponse(AgentFileRefConfig):
|
||||
kind: Literal["file"] = "file"
|
||||
|
||||
|
||||
AgentComposerSkillFileCandidateResponse = Annotated[
|
||||
AgentComposerSkillCandidateResponse | AgentComposerFileCandidateResponse,
|
||||
Field(discriminator="kind"),
|
||||
]
|
||||
|
||||
|
||||
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
|
||||
previous_node_outputs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list)
|
||||
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
|
||||
@ -417,7 +401,6 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
|
||||
|
||||
|
||||
class AgentComposerSoulCandidatesResponse(ResponseModel):
|
||||
skills_files: list[AgentComposerSkillFileCandidateResponse] = Field(default_factory=list)
|
||||
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
|
||||
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
|
||||
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
|
||||
|
||||
@ -7,7 +7,7 @@ Create Date: 2026-06-05 11:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b7c2d9e8a1f4"
|
||||
@ -17,10 +17,23 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
if _has_last_opened_at_column():
|
||||
return
|
||||
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():
|
||||
if not _has_last_opened_at_column():
|
||||
return
|
||||
with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op:
|
||||
batch_op.drop_column("last_opened_at")
|
||||
|
||||
|
||||
def _has_last_opened_at_column() -> bool:
|
||||
if context.is_offline_mode():
|
||||
# Offline SQL generation cannot inspect the target schema. Assume the
|
||||
# linear migration path so generated SQL stays explicit.
|
||||
return False
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
return "last_opened_at" in {column["name"] for column in inspector.get_columns("tenant_account_joins")}
|
||||
|
||||
@ -6,9 +6,15 @@ Create Date: 2026-06-18 23:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.engine.mock import MockConnection
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b2515f9d4c2a"
|
||||
@ -31,9 +37,46 @@ def upgrade() -> None:
|
||||
"agent_drive_files",
|
||||
["tenant_id", "agent_id", "is_skill", "key"],
|
||||
)
|
||||
_remove_skills_files_from_snapshots()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files")
|
||||
op.drop_column("agent_drive_files", "skill_metadata")
|
||||
op.drop_column("agent_drive_files", "is_skill")
|
||||
|
||||
|
||||
def _remove_skills_files_from_snapshots() -> None:
|
||||
connection = op.get_bind()
|
||||
if connection is None or isinstance(connection, MockConnection):
|
||||
return
|
||||
snapshots = sa.table(
|
||||
"agent_config_snapshots",
|
||||
sa.column("id", sa.String()),
|
||||
sa.column("config_snapshot", sa.Text()),
|
||||
)
|
||||
rows = connection.execute(sa.select(snapshots.c.id, snapshots.c.config_snapshot)).fetchall()
|
||||
for row in rows:
|
||||
cleaned = _strip_skills_files(row.config_snapshot)
|
||||
if cleaned is None:
|
||||
continue
|
||||
connection.execute(
|
||||
snapshots.update()
|
||||
.where(snapshots.c.id == row.id)
|
||||
.values(config_snapshot=json.dumps(cleaned, separators=(",", ":"), sort_keys=True))
|
||||
)
|
||||
|
||||
|
||||
def _strip_skills_files(raw_snapshot: Any) -> dict[str, Any] | None:
|
||||
if raw_snapshot is None:
|
||||
return None
|
||||
if isinstance(raw_snapshot, str):
|
||||
snapshot = json.loads(raw_snapshot)
|
||||
elif isinstance(raw_snapshot, dict):
|
||||
snapshot = dict(raw_snapshot)
|
||||
else:
|
||||
snapshot = dict(raw_snapshot)
|
||||
if not isinstance(snapshot, dict) or "skills_files" not in snapshot:
|
||||
return None
|
||||
snapshot.pop("skills_files", None)
|
||||
return snapshot
|
||||
|
||||
@ -361,11 +361,6 @@ class AgentSoulPromptConfig(BaseModel):
|
||||
system_prompt: str = ""
|
||||
|
||||
|
||||
class AgentSoulSkillsFilesConfig(BaseModel):
|
||||
files: list[AgentFileRefConfig] = Field(default_factory=list)
|
||||
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentSoulDifyToolCredentialRef(BaseModel):
|
||||
"""Reference to a stored Dify Plugin Tool credential.
|
||||
|
||||
@ -514,7 +509,6 @@ class AgentSoulConfig(BaseModel):
|
||||
|
||||
schema_version: int = 1
|
||||
prompt: AgentSoulPromptConfig = Field(default_factory=AgentSoulPromptConfig)
|
||||
skills_files: AgentSoulSkillsFilesConfig = Field(default_factory=AgentSoulSkillsFilesConfig)
|
||||
tools: AgentSoulToolsConfig = Field(default_factory=AgentSoulToolsConfig)
|
||||
knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig)
|
||||
human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig)
|
||||
|
||||
@ -1622,7 +1622,7 @@ Inspect one drive-backed skill for slash-menu hover/detail UI
|
||||
| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)<br> |
|
||||
|
||||
### [DELETE] /apps/{app_id}/agent/files
|
||||
Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)
|
||||
Delete one drive file by key via drive commit-null semantics
|
||||
|
||||
#### Parameters
|
||||
|
||||
@ -1708,7 +1708,7 @@ Upload + standardize a Skill into the agent drive
|
||||
| 400 | Invalid skill package or no bound agent | |
|
||||
|
||||
### [DELETE] /apps/{app_id}/agent/skills/{slug}
|
||||
Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)
|
||||
Delete a standardized skill by removing its known drive keys via commit-null
|
||||
|
||||
#### Parameters
|
||||
|
||||
@ -12417,23 +12417,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| provider_id | string | | No |
|
||||
| tools_count | integer | | No |
|
||||
|
||||
#### AgentComposerFileCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| drive_key | string | | No |
|
||||
| file_id | string | | No |
|
||||
| id | string | | No |
|
||||
| kind | string, <br>**Default:** file | | No |
|
||||
| name | string | | No |
|
||||
| reference | string | | No |
|
||||
| remote_url | string | | No |
|
||||
| tenant_id | string | | No |
|
||||
| transfer_method | string | | No |
|
||||
| type | string | | No |
|
||||
| upload_file_id | string | | No |
|
||||
| url | string | | No |
|
||||
|
||||
#### AgentComposerImpactBindingResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -12458,22 +12441,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| previous_node_outputs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No |
|
||||
|
||||
#### AgentComposerSkillCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| file_id | string | | No |
|
||||
| full_archive_file_id | string | | No |
|
||||
| full_archive_key | string | | No |
|
||||
| id | string | | No |
|
||||
| kind | string, <br>**Default:** skill | | No |
|
||||
| manifest_files | [ string ] | | No |
|
||||
| name | string | | No |
|
||||
| path | string | | No |
|
||||
| skill_md_file_id | string | | No |
|
||||
| skill_md_key | string | | No |
|
||||
|
||||
#### AgentComposerSoulCandidatesResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -12482,7 +12449,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
| skills_files | [ ] | | No |
|
||||
|
||||
#### AgentComposerSoulLockResponse
|
||||
|
||||
@ -12603,7 +12569,6 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| config_version_id | string | | No |
|
||||
| removed_keys | [ string ] | | No |
|
||||
| result | string | | Yes |
|
||||
|
||||
@ -12617,7 +12582,6 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| config_version_id | string | | No |
|
||||
| file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes |
|
||||
|
||||
#### AgentDriveFilePayload
|
||||
@ -13203,27 +13167,12 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| enabled | boolean | | No |
|
||||
| type | string | | No |
|
||||
|
||||
#### AgentSkillRefConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| file_id | string | | No |
|
||||
| full_archive_file_id | string | | No |
|
||||
| full_archive_key | string | | No |
|
||||
| id | string | | No |
|
||||
| manifest_files | [ string ] | | No |
|
||||
| name | string | | No |
|
||||
| path | string | | No |
|
||||
| skill_md_file_id | string | | No |
|
||||
| skill_md_key | string | | No |
|
||||
|
||||
#### AgentSkillUploadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| manifest | [SkillManifest](#skillmanifest) | | Yes |
|
||||
| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes |
|
||||
| skill | [AgentUploadedSkillResponse](#agentuploadedskillresponse) | | Yes |
|
||||
|
||||
#### AgentSoulAppFeaturesConfig
|
||||
|
||||
@ -13252,7 +13201,6 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| prompt | [AgentSoulPromptConfig](#agentsoulpromptconfig) | | No |
|
||||
| sandbox | [AgentSoulSandboxConfig](#agentsoulsandboxconfig) | | No |
|
||||
| schema_version | integer, <br>**Default:** 1 | | No |
|
||||
| skills_files | [AgentSoulSkillsFilesConfig](#agentsoulskillsfilesconfig) | | No |
|
||||
| tools | [AgentSoulToolsConfig](#agentsoultoolsconfig) | | No |
|
||||
|
||||
#### AgentSoulDifyToolConfig
|
||||
@ -13369,13 +13317,6 @@ Reference to model credentials resolved only at runtime.
|
||||
| config | [AgentSandboxProviderConfig](#agentsandboxproviderconfig) | | No |
|
||||
| provider | string | | No |
|
||||
|
||||
#### AgentSoulSkillsFilesConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No |
|
||||
| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
|
||||
|
||||
#### AgentSoulToolsConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -13507,6 +13448,16 @@ Soft lifecycle state for Agent records.
|
||||
| tool_output | object | | Yes |
|
||||
| tool_parameters | object | | Yes |
|
||||
|
||||
#### AgentUploadedSkillResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_key | string | | No |
|
||||
| description | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| path | string | | Yes |
|
||||
| skill_md_key | string | | Yes |
|
||||
|
||||
#### AgentUserSatisfactionRateStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -137,9 +137,6 @@ def soul_candidates(
|
||||
soul = agent_soul or AgentSoulConfig()
|
||||
truncated = False
|
||||
|
||||
skills_files = [{"kind": "skill", **skill.model_dump(exclude_none=True)} for skill in soul.skills_files.skills]
|
||||
skills_files += [{"kind": "file", **file.model_dump(exclude_none=True)} for file in soul.skills_files.files]
|
||||
|
||||
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
|
||||
|
||||
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
|
||||
@ -162,7 +159,6 @@ def soul_candidates(
|
||||
dify_tools = workspace_tools_loader()
|
||||
|
||||
lists = {
|
||||
"skills_files": skills_files,
|
||||
"dify_tools": dify_tools,
|
||||
"cli_tools": cli_tools,
|
||||
"knowledge_datasets": knowledge_datasets,
|
||||
|
||||
@ -21,7 +21,6 @@ from models.agent import (
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentFileRefConfig,
|
||||
DeclaredOutputConfig,
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
@ -34,7 +33,6 @@ from services.agent.errors import (
|
||||
AgentNameConflictError,
|
||||
AgentNotFoundError,
|
||||
AgentVersionNotFoundError,
|
||||
InvalidComposerConfigError,
|
||||
)
|
||||
from services.entities.agent_entities import (
|
||||
AgentSoulConfig,
|
||||
@ -106,29 +104,6 @@ class AgentComposerService:
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
|
||||
# ENG-623 §4.4: drive-backed refs must point at real drive rows before the
|
||||
# soul is persisted. Only strategies that write the soul onto an *existing*
|
||||
# agent are checked — new-agent strategies create a fresh (empty) drive, so
|
||||
# any carried drive key would be flagged on the next save instead.
|
||||
if (
|
||||
payload.agent_soul is not None
|
||||
and binding is not None
|
||||
and binding.agent_id
|
||||
and payload.save_strategy
|
||||
in (
|
||||
ComposerSaveStrategy.NODE_JOB_ONLY,
|
||||
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
|
||||
ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
|
||||
)
|
||||
and (
|
||||
payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY
|
||||
or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
|
||||
)
|
||||
):
|
||||
cls._require_drive_refs_resolved(
|
||||
tenant_id=tenant_id, agent_id=binding.agent_id, agent_soul=payload.agent_soul
|
||||
)
|
||||
|
||||
match payload.save_strategy:
|
||||
case ComposerSaveStrategy.NODE_JOB_ONLY:
|
||||
binding = cls._save_node_job_only(
|
||||
@ -176,7 +151,11 @@ class AgentComposerService:
|
||||
version_id=version_id,
|
||||
)
|
||||
state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
|
||||
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
|
||||
state["validation"] = cls.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
agent_id=binding.agent_id,
|
||||
)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
@ -250,9 +229,6 @@ class AgentComposerService:
|
||||
db.session.rollback()
|
||||
raise AgentNameConflictError() from exc
|
||||
|
||||
# ENG-623 §4.4: dangling drive-backed refs are rejected before persisting.
|
||||
cls._require_drive_refs_resolved(tenant_id=tenant_id, agent_id=agent.id, agent_soul=payload.agent_soul)
|
||||
|
||||
if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id:
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
@ -281,7 +257,11 @@ class AgentComposerService:
|
||||
|
||||
db.session.commit()
|
||||
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
|
||||
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
|
||||
state["validation"] = cls.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
agent_id=agent.id,
|
||||
)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
@ -292,11 +272,7 @@ class AgentComposerService:
|
||||
payload: ComposerSavePayload,
|
||||
agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 soft findings, with DB-backed dataset existence for placeholders.
|
||||
|
||||
With ``agent_id`` the drive-backed skill/file refs are also checked against
|
||||
the agent drive (ENG-623 §4.4) and dangling ones surface as warnings.
|
||||
"""
|
||||
"""ENG-617 soft findings, with DB-backed dataset and drive mention checks."""
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
|
||||
mentioned_ids: set[str] = set()
|
||||
@ -312,136 +288,14 @@ class AgentComposerService:
|
||||
findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
|
||||
if agent_id and payload.agent_soul is not None:
|
||||
findings["warnings"].extend(
|
||||
cls._drive_ref_findings(tenant_id=tenant_id, agent_id=agent_id, agent_soul=payload.agent_soul)
|
||||
cls._drive_mention_findings(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prompt=payload.agent_soul.prompt.system_prompt,
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
@classmethod
|
||||
def remove_drive_refs(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
skill_slug: str | None = None,
|
||||
file_key: str | None = None,
|
||||
app_id: str | None = None,
|
||||
node_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Drop the soul refs backed by a drive skill/file before the drive rows go.
|
||||
|
||||
Soul-first ordering (ENG-625 D5): a mid-failure leaves harmless orphan KV
|
||||
rows that an idempotent DELETE retry cleans, instead of a soul ref that
|
||||
keeps failing dangling-ref validation. Returns the new config version id,
|
||||
or ``None`` when the soul held no matching ref (idempotent re-delete).
|
||||
"""
|
||||
if (skill_slug is None) == (file_key is None):
|
||||
raise ValueError("remove_drive_refs requires exactly one of skill_slug or file_key")
|
||||
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1))
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
return None
|
||||
current_snapshot = cls._require_version(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict)
|
||||
|
||||
removed_display: str | None = None
|
||||
if skill_slug is not None:
|
||||
kept_skills = []
|
||||
for skill in agent_soul.skills_files.skills:
|
||||
slug = (skill.skill_md_key or "").split("/", 1)[0] or (skill.path or "").strip("/")
|
||||
if slug == skill_slug:
|
||||
removed_display = skill.name or skill.id or skill_slug
|
||||
continue
|
||||
kept_skills.append(skill)
|
||||
if removed_display is None:
|
||||
return None
|
||||
agent_soul.skills_files.skills = kept_skills
|
||||
note = f"Removed skill '{removed_display}' from the drive."
|
||||
else:
|
||||
kept_files = []
|
||||
for file in agent_soul.skills_files.files:
|
||||
if file.drive_key == file_key:
|
||||
removed_display = file.name or file.drive_key
|
||||
continue
|
||||
kept_files.append(file)
|
||||
if removed_display is None:
|
||||
return None
|
||||
agent_soul.skills_files.files = kept_files
|
||||
note = f"Removed file '{removed_display}' from the drive."
|
||||
|
||||
version = cls._update_current_version(
|
||||
current_snapshot=current_snapshot,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
version_note=note,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.updated_by = account_id
|
||||
cls._sync_draft_binding_snapshot(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
node_id=node_id,
|
||||
agent_id=agent_id,
|
||||
snapshot_id=version.id,
|
||||
account_id=account_id,
|
||||
)
|
||||
db.session.commit()
|
||||
return version.id
|
||||
|
||||
@classmethod
|
||||
def add_drive_file_ref(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
file_ref: AgentFileRefConfig,
|
||||
app_id: str | None = None,
|
||||
node_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Add or replace one drive-backed file ref in the active Agent Soul.
|
||||
|
||||
``POST /agent/files`` is an ADD FILE user action, not just a low-level
|
||||
drive commit. The committed file must be present in ``skills_files.files``
|
||||
because runtime ``dify.drive`` is built from the active Agent Soul.
|
||||
"""
|
||||
if not file_ref.drive_key:
|
||||
raise ValueError("file_ref.drive_key is required")
|
||||
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1))
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
return None
|
||||
current_snapshot = cls._require_version(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict)
|
||||
kept_files = [item for item in agent_soul.skills_files.files if item.drive_key != file_ref.drive_key]
|
||||
kept_files.append(file_ref)
|
||||
agent_soul.skills_files.files = kept_files
|
||||
|
||||
display = file_ref.name or file_ref.drive_key
|
||||
version = cls._update_current_version(
|
||||
current_snapshot=current_snapshot,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
version_note=f"Added file '{display}' to the drive.",
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.updated_by = account_id
|
||||
cls._sync_draft_binding_snapshot(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
node_id=node_id,
|
||||
agent_id=agent_id,
|
||||
snapshot_id=version.id,
|
||||
account_id=account_id,
|
||||
)
|
||||
db.session.commit()
|
||||
return version.id
|
||||
|
||||
@classmethod
|
||||
def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None:
|
||||
"""The Agent App's bound roster agent id, if any (validate-endpoint context)."""
|
||||
@ -468,49 +322,25 @@ class AgentComposerService:
|
||||
return binding.agent_id if binding else None
|
||||
|
||||
@classmethod
|
||||
def _sync_draft_binding_snapshot(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
app_id: str | None,
|
||||
node_id: str | None,
|
||||
agent_id: str,
|
||||
snapshot_id: str,
|
||||
account_id: str,
|
||||
) -> None:
|
||||
"""Keep workflow node bindings on the new active snapshot after direct drive edits."""
|
||||
if not app_id or not node_id:
|
||||
return
|
||||
try:
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
except ValueError:
|
||||
return
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
if binding is None or binding.agent_id != agent_id:
|
||||
return
|
||||
binding.current_snapshot_id = snapshot_id
|
||||
binding.updated_by = account_id
|
||||
|
||||
@classmethod
|
||||
def _drive_ref_findings(
|
||||
def _drive_mention_findings(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
agent_soul: AgentSoulConfig,
|
||||
prompt: str,
|
||||
) -> list[dict[str, str | None]]:
|
||||
"""Drive-backed refs whose keys have no row in the agent drive (ENG-623 §4.4).
|
||||
"""Soft warnings for missing drive-backed prompt mentions."""
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
from services.agent_drive_service import decode_drive_mention_ref
|
||||
|
||||
Each finding message starts with its stable code token
|
||||
(``skill_ref_dangling`` / ``file_ref_dangling``) in the ENG-616/617 style.
|
||||
"""
|
||||
wanted_keys: dict[str, tuple[str, str]] = {}
|
||||
for skill in agent_soul.skills_files.skills:
|
||||
if skill.skill_md_key:
|
||||
wanted_keys[skill.skill_md_key] = ("skill_ref_dangling", skill.name or skill.id or "unknown")
|
||||
for file in agent_soul.skills_files.files:
|
||||
if file.drive_key:
|
||||
wanted_keys[file.drive_key] = ("file_ref_dangling", file.name or file.id or "unknown")
|
||||
for mention in parse_prompt_mentions(prompt):
|
||||
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
|
||||
continue
|
||||
decoded_key = decode_drive_mention_ref(mention.ref_id)
|
||||
if not decoded_key:
|
||||
continue
|
||||
wanted_keys[decoded_key] = (mention.kind.value, mention.label or decoded_key)
|
||||
if not wanted_keys:
|
||||
return []
|
||||
|
||||
@ -524,28 +354,20 @@ class AgentComposerService:
|
||||
)
|
||||
)
|
||||
findings: list[dict[str, str | None]] = []
|
||||
for key, (code, display) in wanted_keys.items():
|
||||
for key, (kind, display) in wanted_keys.items():
|
||||
if key in existing_keys:
|
||||
continue
|
||||
kind = "skill" if code == "skill_ref_dangling" else "file"
|
||||
findings.append(
|
||||
{
|
||||
"code": code,
|
||||
"code": "mention_target_missing",
|
||||
"surface": "agent_soul",
|
||||
"kind": kind,
|
||||
"id": key,
|
||||
"message": f"{code}: {kind} '{display}' has no drive entry for key '{key}'.",
|
||||
"message": f"{kind} '{display}' has no drive entry for key '{key}'.",
|
||||
}
|
||||
)
|
||||
return findings
|
||||
|
||||
@classmethod
|
||||
def _require_drive_refs_resolved(cls, *, tenant_id: str, agent_id: str, agent_soul: AgentSoulConfig) -> None:
|
||||
"""Hard save-time guard: dangling drive-backed refs are rejected (400)."""
|
||||
findings = cls._drive_ref_findings(tenant_id=tenant_id, agent_id=agent_id, agent_soul=agent_soul)
|
||||
if findings:
|
||||
raise InvalidComposerConfigError("; ".join(str(finding["message"]) for finding in findings))
|
||||
|
||||
@classmethod
|
||||
def get_workflow_candidates(cls, *, tenant_id: str, app_id: str, node_id: str, user_id: str) -> dict[str, Any]:
|
||||
"""Slash-menu data source for the workflow Agent node composer (ENG-615)."""
|
||||
|
||||
@ -191,6 +191,8 @@ class ComposerConfigValidator:
|
||||
}
|
||||
)
|
||||
continue
|
||||
if mention.kind in {MentionKind.SKILL, MentionKind.FILE}:
|
||||
continue
|
||||
if resolved is None:
|
||||
warnings.append(
|
||||
{
|
||||
|
||||
@ -4,13 +4,14 @@ Slash-menu insertions are stored inline in the plain-string prompt as tokens:
|
||||
|
||||
[§<kind>:<id>[:<label>]§]
|
||||
|
||||
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent config
|
||||
lists (mentions are pointers — the entity itself lives in ``skills_files`` /
|
||||
``tools`` / ``knowledge.datasets`` / ``human.contacts`` /
|
||||
``previous_node_output_refs`` / ``declared_outputs``); ``label`` is an optional
|
||||
plain-text fallback only (the backend always re-resolves by id, so renames never
|
||||
break references). A single ``:`` separates all three fields; ``label`` is the
|
||||
trailing remainder and may itself contain ``:``.
|
||||
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent
|
||||
runtime context. For prompt-owned entities that means Agent Soul lists such as
|
||||
``tools`` / ``knowledge.datasets`` / ``human.contacts`` and workflow job lists
|
||||
such as ``previous_node_output_refs`` / ``declared_outputs``. For drive-backed
|
||||
``skill`` / ``file`` mentions the field stores a URL-encoded drive key and is
|
||||
resolved against ``agent_drive_files`` at runtime. ``label`` is an optional
|
||||
plain-text fallback only. A single ``:`` separates all three fields; ``label``
|
||||
is the trailing remainder and may itself contain ``:``.
|
||||
|
||||
The ``[§…§]`` wrapper uses the section sign ``§`` (U+00A7), which never appears
|
||||
in Dify template syntax (``{{var}}`` / ``{{#a.b#}}``) nor in normal prompt text,
|
||||
@ -55,7 +56,11 @@ MENTION_PATTERN = re.compile(
|
||||
_RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\]")
|
||||
|
||||
MAX_MENTIONS_PER_PROMPT = 200
|
||||
MAX_MENTION_FIELD_LENGTH = 255
|
||||
# Drive keys are validated up to 512 Unicode code points before URL encoding.
|
||||
# Worst case, one code point becomes 4 UTF-8 bytes and each byte becomes a
|
||||
# 3-character ``%XX`` escape, so a valid encoded drive key can reach 6144 chars.
|
||||
MAX_MENTION_REF_ID_LENGTH = 6144
|
||||
MAX_MENTION_LABEL_LENGTH = 255
|
||||
|
||||
# Reserved ``tool`` mention id suffix: ``<provider>/*`` means "every tool of this
|
||||
# provider" (a provider hosts many tools, like an MCP server). Single tools use
|
||||
@ -102,7 +107,7 @@ def parse_prompt_mentions(prompt: str) -> list[PromptMention]:
|
||||
for match in MENTION_PATTERN.finditer(prompt or ""):
|
||||
ref_id = match.group(2)
|
||||
label = match.group(3)
|
||||
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
|
||||
if len(ref_id) > MAX_MENTION_REF_ID_LENGTH or (label is not None and len(label) > MAX_MENTION_LABEL_LENGTH):
|
||||
continue
|
||||
mentions.append(
|
||||
PromptMention(
|
||||
@ -127,8 +132,8 @@ def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
ref_id = match.group(2)
|
||||
label = match.group(3) or None
|
||||
fallback = (label or ref_id)[:MAX_MENTION_FIELD_LENGTH]
|
||||
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
|
||||
fallback = (label or ref_id)[:MAX_MENTION_LABEL_LENGTH]
|
||||
if len(ref_id) > MAX_MENTION_REF_ID_LENGTH or (label is not None and len(label) > MAX_MENTION_LABEL_LENGTH):
|
||||
return fallback
|
||||
mention = PromptMention(
|
||||
kind=MentionKind(match.group(1)),
|
||||
@ -141,7 +146,7 @@ def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
|
||||
resolved = resolver(mention)
|
||||
if resolved is None or not resolved.strip():
|
||||
return fallback
|
||||
return resolved[:MAX_MENTION_FIELD_LENGTH]
|
||||
return resolved[:MAX_MENTION_LABEL_LENGTH]
|
||||
|
||||
return scrub_mention_markers(MENTION_PATTERN.sub(_replace, prompt))
|
||||
|
||||
@ -163,27 +168,19 @@ def scrub_mention_markers(text: str) -> str:
|
||||
# inner is ``kind:id[:label]``; prefer the label, else the id.
|
||||
parts = match.group(1).split(":", 2)
|
||||
if len(parts) >= 3 and parts[2].strip():
|
||||
return parts[2].strip()[:MAX_MENTION_FIELD_LENGTH]
|
||||
return parts[2].strip()[:MAX_MENTION_LABEL_LENGTH]
|
||||
if len(parts) >= 2 and parts[1].strip():
|
||||
return parts[1].strip()[:MAX_MENTION_FIELD_LENGTH]
|
||||
return match.group(1)[:MAX_MENTION_FIELD_LENGTH]
|
||||
return parts[1].strip()[:MAX_MENTION_LABEL_LENGTH]
|
||||
return match.group(1)[:MAX_MENTION_LABEL_LENGTH]
|
||||
|
||||
return _RESIDUAL_MENTION_PATTERN.sub(_degrade, text)
|
||||
|
||||
|
||||
def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
|
||||
"""Resolve soul-surface mentions to canonical display names from the soul config."""
|
||||
"""Resolve non-drive soul-surface mentions to canonical display names."""
|
||||
|
||||
def _resolve(mention: PromptMention) -> str | None:
|
||||
match mention.kind:
|
||||
case MentionKind.SKILL:
|
||||
for skill in agent_soul.skills_files.skills:
|
||||
if mention.ref_id in (skill.id, skill.name):
|
||||
return skill.name or skill.id
|
||||
case MentionKind.FILE:
|
||||
for file in agent_soul.skills_files.files:
|
||||
if mention.ref_id in (file.id, file.name):
|
||||
return file.name or file.id
|
||||
case MentionKind.TOOL:
|
||||
for tool in agent_soul.tools.dify_tools:
|
||||
prefixes = {prefix for prefix in (tool.provider, tool.provider_id, tool.plugin_id) if prefix}
|
||||
@ -273,7 +270,8 @@ def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] |
|
||||
__all__ = [
|
||||
"ALL_PROVIDER_TOOLS_SUFFIX",
|
||||
"MAX_MENTIONS_PER_PROMPT",
|
||||
"MAX_MENTION_FIELD_LENGTH",
|
||||
"MAX_MENTION_LABEL_LENGTH",
|
||||
"MAX_MENTION_REF_ID_LENGTH",
|
||||
"MENTION_PATTERN",
|
||||
"NODE_JOB_PROMPT_ALLOWED_KINDS",
|
||||
"SOUL_PROMPT_ALLOWED_KINDS",
|
||||
|
||||
@ -4,11 +4,12 @@ A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entr
|
||||
file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``,
|
||||
followed by markdown instructions). This service validates the archive (extension,
|
||||
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields) and
|
||||
extracts a manifest the API can bind to an Agent config version's skill list.
|
||||
extracts a manifest consumed by drive standardization.
|
||||
|
||||
It does NOT execute or load the skill — the agent backend owns execution. It also
|
||||
does not (here) standardize the package into the agent drive; that is ENG-594 (S6),
|
||||
which consumes the manifest produced here.
|
||||
does not persist anything into Agent Soul or bind anything to config versions;
|
||||
``SkillStandardizeService`` consumes the manifest and commits the canonical drive
|
||||
rows instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -22,8 +23,6 @@ import zipfile
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from models.agent_config_entities import AgentSkillRefConfig
|
||||
|
||||
# Bounds — generous but finite so a hostile upload can't exhaust memory/disk.
|
||||
_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
|
||||
_MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024
|
||||
@ -58,22 +57,6 @@ class SkillManifest(BaseModel):
|
||||
size: int # total uncompressed bytes
|
||||
hash: str # sha256 of the archive bytes
|
||||
|
||||
def to_skill_ref(self, *, file_id: str, path: str | None = None) -> AgentSkillRefConfig:
|
||||
"""Build a config skill ref. ``path`` is the stable drive path (set by S6)."""
|
||||
return AgentSkillRefConfig.model_validate(
|
||||
{
|
||||
"id": self.hash,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"file_id": file_id,
|
||||
"path": path,
|
||||
"size": self.size,
|
||||
"hash": self.hash,
|
||||
"entry_path": self.entry_path,
|
||||
"manifest_files": self.files,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SkillPackageService:
|
||||
"""Validate Skill archives and extract their manifest."""
|
||||
|
||||
@ -9,10 +9,11 @@ to the agent drive (Agent Files §5.4 / §4):
|
||||
|
||||
Both are stored as ``ToolFile`` records and bound via ``AgentDriveService.commit``
|
||||
with ``value_owned_by_drive=True`` (the drive owns their lifecycle). The returned
|
||||
skill ref records the stable drive paths + file ids (not just the raw upload id),
|
||||
so the Composer can reload the bound skill list. The console ``/skills/upload``
|
||||
endpoints delegate to this service so "upload" now always means drive-backed skill
|
||||
normalization.
|
||||
payload is the slim drive-derived skill DTO the UI needs to work with the drive
|
||||
catalog — ``name``, ``description``, ``path``, ``skill_md_key``, and
|
||||
``archive_key`` — plus the extracted manifest for upload feedback. The console
|
||||
``/skills/upload`` endpoints delegate to this service so "upload" now always means
|
||||
drive-backed skill normalization rather than Agent Soul binding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -23,7 +24,6 @@ import re
|
||||
from typing import Any
|
||||
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from models.agent_config_entities import AgentSkillRefConfig
|
||||
from services.agent.skill_package_service import SkillPackageService
|
||||
from services.agent_drive_service import AgentDriveService, DriveCommitItem, DriveFileRef, DriveSkillMetadata
|
||||
|
||||
@ -134,26 +134,20 @@ class SkillStandardizeService:
|
||||
],
|
||||
)
|
||||
|
||||
skill_ref = AgentSkillRefConfig.model_validate(
|
||||
{
|
||||
"id": manifest.hash,
|
||||
"name": manifest.name,
|
||||
"description": manifest.description,
|
||||
"file_id": archive_tool_file.id,
|
||||
"path": slug,
|
||||
"size": manifest.size,
|
||||
"hash": manifest.hash,
|
||||
"entry_path": skill_md_key,
|
||||
"skill_md_file_id": md_tool_file.id,
|
||||
"skill_md_key": skill_md_key,
|
||||
"full_archive_file_id": archive_tool_file.id,
|
||||
"full_archive_key": archive_key,
|
||||
# ENG-371: zip member listing — strong signals (scripts/*.sh) for infer-tools.
|
||||
"manifest_files": manifest.files,
|
||||
}
|
||||
drive_skill = next(
|
||||
skill
|
||||
for skill in self._drive.list_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if skill["skill_md_key"] == skill_md_key
|
||||
)
|
||||
|
||||
return {
|
||||
"skill": skill_ref.model_dump(exclude_none=True),
|
||||
"skill": {
|
||||
"name": drive_skill["name"],
|
||||
"description": drive_skill["description"],
|
||||
"path": drive_skill["path"],
|
||||
"skill_md_key": drive_skill["skill_md_key"],
|
||||
"archive_key": drive_skill["archive_key"],
|
||||
},
|
||||
"manifest": manifest.model_dump(),
|
||||
}
|
||||
|
||||
|
||||
@ -19,15 +19,11 @@ from typing import Any
|
||||
|
||||
import json_repair
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.errors.error import ProviderTokenNotInitError
|
||||
from core.model_manager import ModelManager
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.agent import Agent
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -97,12 +93,8 @@ class SkillToolInferenceService:
|
||||
|
||||
def infer(self, *, tenant_id: str, agent_id: str, slug: str) -> dict[str, Any]:
|
||||
skill_md = self._load_skill_md(tenant_id=tenant_id, agent_id=agent_id, slug=slug)
|
||||
manifest_files = self._manifest_files_from_soul(tenant_id=tenant_id, agent_id=agent_id, slug=slug)
|
||||
|
||||
user_prompt = f"SKILL.md of skill '{slug}':\n\n{skill_md}"
|
||||
if manifest_files:
|
||||
listing = "\n".join(manifest_files[:200])
|
||||
user_prompt += f"\n\nFiles inside the skill package:\n{listing}"
|
||||
|
||||
raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt)
|
||||
try:
|
||||
@ -138,37 +130,6 @@ class SkillToolInferenceService:
|
||||
)
|
||||
return str(preview["text"])
|
||||
|
||||
@staticmethod
|
||||
def _manifest_files_from_soul(*, tenant_id: str, agent_id: str, slug: str) -> list[str]:
|
||||
"""The zip path listing standardize persisted onto the ref, if present.
|
||||
|
||||
Degrades to an empty list (SKILL.md-only inference) for refs that
|
||||
predate ``manifest_files``.
|
||||
"""
|
||||
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1))
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
return []
|
||||
from models.agent import AgentConfigSnapshot
|
||||
|
||||
snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot).where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
|
||||
)
|
||||
)
|
||||
if snapshot is None:
|
||||
return []
|
||||
soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
for skill in soul.skills_files.skills:
|
||||
ref_slug = (skill.skill_md_key or "").split("/", 1)[0] or (skill.path or "").strip("/")
|
||||
if ref_slug != slug:
|
||||
continue
|
||||
files = skill.get("manifest_files")
|
||||
if isinstance(files, list):
|
||||
return [str(item) for item in files]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _invoke(*, tenant_id: str, user_prompt: str) -> str:
|
||||
try:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Agent 网盘 (agent drive) service — list/manifest + commit with lifecycle (ENG-591).
|
||||
"""Agent 网盘 (agent drive) service — manifest/catalog + commit lifecycle.
|
||||
|
||||
The agent drive is a per-agent path-like KV index over existing UploadFile /
|
||||
ToolFile records (see ``AgentDriveFile``). This service is the control plane:
|
||||
@ -8,11 +8,13 @@ ToolFile records (see ``AgentDriveFile``). This service is the control plane:
|
||||
``FileAccessScope`` (Agent Files §3.1.2). We reuse the standard
|
||||
``file_factory.build_from_mapping`` + ``resolve_file_url`` rebuild, which always
|
||||
filters by ``tenant_id`` in the builders, so omitting the scope is safe.
|
||||
* ``commit`` binds a batch of existing file refs to keys. Source ToolFiles must
|
||||
* ``commit`` is the single mutation entry point for writes and removals.
|
||||
``file_ref=None`` removes an exact key idempotently; otherwise the service
|
||||
binds the referenced UploadFile/ToolFile to the key. Source ToolFiles must
|
||||
belong to the current run user. Overwriting a key whose previous value is
|
||||
``value_owned_by_drive`` physically cleans the old value (storage + record),
|
||||
unless another drive entry still references it. Re-committing the same
|
||||
``key -> file_ref`` is idempotent.
|
||||
``key -> file_ref`` is idempotent and still refreshes skill metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -30,7 +32,6 @@ from sqlalchemy.exc import DataError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.file_access.controller import DatabaseFileAccessController
|
||||
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
|
||||
from core.db.session_factory import session_factory
|
||||
from extensions.ext_storage import storage
|
||||
from factories import file_factory
|
||||
@ -93,7 +94,7 @@ class DriveCommitItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str
|
||||
file_ref: DriveFileRef
|
||||
file_ref: DriveFileRef | None = None
|
||||
# Drive-owned values may be physically cleaned on overwrite/removal; refs to
|
||||
# files shared with other business records should set this False.
|
||||
value_owned_by_drive: bool = True
|
||||
@ -385,6 +386,15 @@ class AgentDriveService:
|
||||
pending_storage_deletes: list[str],
|
||||
) -> dict[str, Any]:
|
||||
key = normalize_drive_key(item.key)
|
||||
if item.file_ref is None:
|
||||
return self._remove_one(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
pending_storage_deletes=pending_storage_deletes,
|
||||
)
|
||||
|
||||
skill_metadata = self._validate_skill_commit_fields(key=key, item=item)
|
||||
file_kind = AgentDriveFileKind(item.file_ref.kind)
|
||||
file_id = item.file_ref.id
|
||||
@ -447,6 +457,45 @@ class AgentDriveService:
|
||||
session.add(row)
|
||||
return self._row_dict(row)
|
||||
|
||||
def _remove_one(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
pending_storage_deletes: list[str],
|
||||
) -> dict[str, Any]:
|
||||
existing = session.scalar(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
AgentDriveFile.key == key,
|
||||
)
|
||||
)
|
||||
if existing is None:
|
||||
return {"key": key, "removed": True, "noop": True}
|
||||
result = {
|
||||
"key": key,
|
||||
"removed": True,
|
||||
"file_kind": existing.file_kind.value,
|
||||
"file_id": existing.file_id,
|
||||
"value_owned_by_drive": existing.value_owned_by_drive,
|
||||
"is_skill": existing.is_skill,
|
||||
"skill_metadata": existing.skill_metadata,
|
||||
}
|
||||
if existing.value_owned_by_drive:
|
||||
self._cleanup_value(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
file_kind=existing.file_kind,
|
||||
file_id=existing.file_id,
|
||||
exclude_row_id=existing.id,
|
||||
pending_storage_deletes=pending_storage_deletes,
|
||||
)
|
||||
session.delete(existing)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _row_dict(row: AgentDriveFile) -> dict[str, Any]:
|
||||
return {
|
||||
@ -750,6 +799,11 @@ class AgentDriveService:
|
||||
else:
|
||||
mapping = {"transfer_method": "local_file", "upload_file_id": file_id}
|
||||
controller = DatabaseFileAccessController()
|
||||
# Keep workflow runtime wiring lazy: importing this service is part of
|
||||
# Agent v2 node bootstrap, while ``core.app.workflow`` re-exports the
|
||||
# node factory. A module-level import here would close that cycle.
|
||||
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
|
||||
|
||||
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
|
||||
try:
|
||||
if file_kind == AgentDriveFileKind.UPLOAD_FILE:
|
||||
|
||||
@ -14,6 +14,7 @@ from dify_agent.layers.dify_plugin import (
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.drive import DifyDriveLayerConfig
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID
|
||||
@ -42,7 +43,7 @@ from clients.agent_backend import (
|
||||
extract_runtime_layer_specs,
|
||||
redact_for_agent_backend_log,
|
||||
)
|
||||
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
|
||||
from clients.agent_backend.request_builder import DIFY_DRIVE_LAYER_ID, DIFY_SHELL_LAYER_ID
|
||||
|
||||
|
||||
def _run_input() -> AgentBackendWorkflowNodeRunInput:
|
||||
@ -331,6 +332,22 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell():
|
||||
assert shell_config.env[0].name == "PROJECT_NAME"
|
||||
|
||||
|
||||
def test_workflow_request_builder_binds_shell_to_drive_when_configured():
|
||||
run_input = _run_input()
|
||||
run_input.include_shell = True
|
||||
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
layer_names = [layer.name for layer in request.composition.layers]
|
||||
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"drive": DIFY_DRIVE_LAYER_ID,
|
||||
}
|
||||
assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID)
|
||||
|
||||
|
||||
def test_agent_app_request_builder_omits_shell_layer_by_default():
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(_agent_app_input())
|
||||
assert DIFY_SHELL_LAYER_ID not in {layer.name for layer in request.composition.layers}
|
||||
@ -350,6 +367,21 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
|
||||
assert shell_config.env[0].name == "APP_ENV"
|
||||
|
||||
|
||||
def test_agent_app_request_builder_binds_shell_to_drive_when_configured():
|
||||
run_input = _agent_app_input(include_shell=True)
|
||||
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
layer_names = [layer.name for layer in request.composition.layers]
|
||||
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"drive": DIFY_DRIVE_LAYER_ID,
|
||||
}
|
||||
assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID)
|
||||
|
||||
|
||||
def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
|
||||
run_input = _agent_app_input()
|
||||
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
|
||||
@ -12,6 +12,7 @@ import io
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.app.agent import (
|
||||
@ -152,27 +153,20 @@ def test_files_commit_validates_upload_and_returns_drive_ref():
|
||||
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 qna.pdf", "size": 5, "mime_type": "application/pdf"}
|
||||
]
|
||||
composer.add_drive_file_ref.return_value = "ver-2"
|
||||
body, status = raw(AgentDriveFilesApi(), _USER, _APP)
|
||||
|
||||
assert status == 201
|
||||
assert body["file"]["drive_key"] == "files/sample qna.pdf"
|
||||
assert body["file"]["file_id"] == "uf-1"
|
||||
assert body["config_version_id"] == "ver-2"
|
||||
item = drive.return_value.commit.call_args.kwargs["items"][0]
|
||||
assert item.value_owned_by_drive is True
|
||||
assert item.file_ref.kind == "upload_file"
|
||||
file_ref = composer.add_drive_file_ref.call_args.kwargs["file_ref"]
|
||||
assert file_ref.drive_key == "files/sample qna.pdf"
|
||||
assert file_ref.name == "sample qna.pdf"
|
||||
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():
|
||||
@ -184,20 +178,16 @@ def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id():
|
||||
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():
|
||||
@ -234,13 +224,10 @@ def test_files_commit_resolves_workflow_node_agent():
|
||||
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(AgentDriveFilesApi(), _USER, _WORKFLOW_APP)
|
||||
|
||||
assert status == 201
|
||||
assert body["config_version_id"] == "ver-2"
|
||||
assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
|
||||
assert composer.add_drive_file_ref.call_args.kwargs["node_id"] == "agent-node-1"
|
||||
|
||||
|
||||
def test_files_delete_updates_soul_then_drive():
|
||||
@ -250,17 +237,15 @@ def test_files_delete_updates_soul_then_drive():
|
||||
calls: list[str] = []
|
||||
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"):
|
||||
with (
|
||||
patch(f"{_MOD}.AgentComposerService") as composer,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
composer.remove_drive_refs.side_effect = lambda **kw: calls.append("soul") or "ver-2"
|
||||
drive.return_value.delete.side_effect = lambda **kw: calls.append("drive") or ["files/sample.pdf"]
|
||||
drive.return_value.commit.side_effect = lambda **kw: (
|
||||
calls.append("drive") or [{"key": "files/sample.pdf", "removed": True}]
|
||||
)
|
||||
body = raw(AgentDriveFilesApi(), _USER, _APP)
|
||||
|
||||
assert calls == ["soul", "drive"] # soul-first ordering
|
||||
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"], "config_version_id": "ver-2"}
|
||||
assert composer.remove_drive_refs.call_args.kwargs["file_key"] == "files/sample.pdf"
|
||||
assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1"
|
||||
assert calls == ["drive"]
|
||||
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
|
||||
|
||||
|
||||
def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id():
|
||||
@ -268,16 +253,13 @@ def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id():
|
||||
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"]
|
||||
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
|
||||
body = raw(AgentDriveFilesByAgentApi(), "tenant-1", _USER, "agent-1")
|
||||
|
||||
assert body["config_version_id"] == "ver-2"
|
||||
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
|
||||
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():
|
||||
@ -290,13 +272,11 @@ def test_files_delete_resolves_workflow_node_agent():
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
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"]
|
||||
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
|
||||
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"
|
||||
assert composer.remove_drive_refs.call_args.kwargs["node_id"] == "agent-node-1"
|
||||
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
|
||||
assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
|
||||
|
||||
|
||||
def test_files_delete_survives_drive_failure():
|
||||
@ -305,14 +285,11 @@ def test_files_delete_survives_drive_failure():
|
||||
raw = _raw(AgentDriveFilesApi.delete)
|
||||
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"):
|
||||
with (
|
||||
patch(f"{_MOD}.AgentComposerService") as composer,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
composer.remove_drive_refs.return_value = "ver-2"
|
||||
drive.return_value.delete.side_effect = RuntimeError("storage down")
|
||||
body = raw(AgentDriveFilesApi(), _USER, _APP)
|
||||
# soul already updated; drive cleanup is best-effort and retryable
|
||||
assert body == {"result": "success", "removed_keys": [], "config_version_id": "ver-2"}
|
||||
drive.return_value.commit.side_effect = RuntimeError("storage down")
|
||||
with pytest.raises(RuntimeError, match="storage down"):
|
||||
raw(AgentDriveFilesApi(), _USER, _APP)
|
||||
|
||||
|
||||
def test_skill_delete_uses_slug_prefix_and_is_idempotent():
|
||||
@ -321,17 +298,18 @@ def test_skill_delete_uses_slug_prefix_and_is_idempotent():
|
||||
raw = _raw(AgentSkillApi.delete)
|
||||
with _json_ctx(method="DELETE"):
|
||||
with (
|
||||
patch(f"{_MOD}.AgentComposerService") as composer,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
composer.remove_drive_refs.return_value = None # ref already gone
|
||||
drive.return_value.delete.return_value = []
|
||||
drive.return_value.commit.return_value = [
|
||||
{"key": "tender-analyzer/SKILL.md", "removed": True},
|
||||
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "removed": True},
|
||||
]
|
||||
body = raw(AgentSkillApi(), _USER, _APP, "tender-analyzer")
|
||||
|
||||
assert body == {"result": "success", "removed_keys": [], "config_version_id": None}
|
||||
assert drive.return_value.delete.call_args.kwargs["prefix"] == "tender-analyzer/"
|
||||
assert composer.remove_drive_refs.call_args.kwargs["skill_slug"] == "tender-analyzer"
|
||||
assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1"
|
||||
assert body == {
|
||||
"result": "success",
|
||||
"removed_keys": ["tender-analyzer/SKILL.md", "tender-analyzer/.DIFY-SKILL-FULL.zip"],
|
||||
}
|
||||
|
||||
|
||||
def test_skill_delete_by_agent_uses_agent_route():
|
||||
@ -339,16 +317,13 @@ def test_skill_delete_by_agent_uses_agent_route():
|
||||
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"]
|
||||
drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}]
|
||||
body = raw(AgentSkillByAgentApi(), "tenant-1", _USER, "agent-1", "tender-analyzer")
|
||||
|
||||
assert body["config_version_id"] == "ver-2"
|
||||
assert body == {"result": "success", "removed_keys": ["tender-analyzer/SKILL.md"]}
|
||||
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():
|
||||
|
||||
@ -8,12 +8,13 @@ controller's request parsing + error mapping, not auth (tested separately).
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.inner_api.plugin.agent_drive import AgentDriveCommitApi, AgentDriveManifestApi
|
||||
from controllers.inner_api.plugin.agent_drive import AgentDriveCommitApi, AgentDriveManifestApi, AgentDriveSkillsApi
|
||||
from services.agent_drive_service import AgentDriveError
|
||||
|
||||
_MOD = "controllers.inner_api.plugin.agent_drive"
|
||||
@ -52,6 +53,41 @@ def test_manifest_bad_drive_ref_is_400():
|
||||
assert body["code"] == "invalid_drive_ref"
|
||||
|
||||
|
||||
def test_skills_requires_tenant_id_and_returns_items():
|
||||
raw = _raw(AgentDriveSkillsApi.get)
|
||||
|
||||
with app.test_request_context("/"):
|
||||
body, status = raw(AgentDriveSkillsApi(), "agent-agent-1")
|
||||
assert status == 400
|
||||
assert body["code"] == "missing_tenant_id"
|
||||
|
||||
with app.test_request_context("/?tenant_id=tenant-1"):
|
||||
with patch(f"{_MOD}.AgentDriveService") as svc:
|
||||
svc.return_value.list_skills.return_value = [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
}
|
||||
]
|
||||
result = raw(AgentDriveSkillsApi(), "agent-agent-1")
|
||||
|
||||
assert result == {
|
||||
"items": [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
}
|
||||
]
|
||||
}
|
||||
assert svc.return_value.list_skills.call_args.kwargs == {"tenant_id": "tenant-1", "agent_id": "agent-1"}
|
||||
|
||||
|
||||
def test_commit_parses_body_and_returns_items():
|
||||
raw = _raw(AgentDriveCommitApi.post)
|
||||
payload = {
|
||||
@ -60,11 +96,35 @@ def test_commit_parses_body_and_returns_items():
|
||||
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
|
||||
}
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with patch(f"{_MOD}.AgentDriveService") as svc:
|
||||
with (
|
||||
patch(f"{_MOD}.get_user", return_value=SimpleNamespace(id="user-1")) as get_user,
|
||||
patch(f"{_MOD}.AgentDriveService") as svc,
|
||||
):
|
||||
svc.return_value.commit.return_value = [{"key": "a.txt"}]
|
||||
result = raw(AgentDriveCommitApi(), "agent-agent-1")
|
||||
assert result == {"items": [{"key": "a.txt"}]}
|
||||
assert get_user.call_args.args == ("tenant-1", "user-1")
|
||||
assert svc.return_value.commit.call_args.kwargs["agent_id"] == "agent-1"
|
||||
assert svc.return_value.commit.call_args.kwargs["user_id"] == "user-1"
|
||||
|
||||
|
||||
def test_commit_canonicalizes_user_before_service_call():
|
||||
raw = _raw(AgentDriveCommitApi.post)
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "session-1",
|
||||
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
|
||||
}
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with (
|
||||
patch(f"{_MOD}.get_user", return_value=SimpleNamespace(id="end-user-1")),
|
||||
patch(f"{_MOD}.AgentDriveService") as svc,
|
||||
):
|
||||
svc.return_value.commit.return_value = [{"key": "a.txt"}]
|
||||
result = raw(AgentDriveCommitApi(), "agent-agent-1")
|
||||
|
||||
assert result == {"items": [{"key": "a.txt"}]}
|
||||
assert svc.return_value.commit.call_args.kwargs["user_id"] == "end-user-1"
|
||||
|
||||
|
||||
def test_commit_invalid_body_is_400():
|
||||
@ -83,13 +143,16 @@ def test_commit_maps_service_error():
|
||||
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
|
||||
}
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with patch(f"{_MOD}.AgentDriveService") as svc:
|
||||
with (
|
||||
patch(f"{_MOD}.get_user", return_value=SimpleNamespace(id="user-1")),
|
||||
patch(f"{_MOD}.AgentDriveService") as svc,
|
||||
):
|
||||
svc.return_value.commit.side_effect = AgentDriveError("source_not_found", "nope", status_code=404)
|
||||
body, status = raw(AgentDriveCommitApi(), "agent-agent-1")
|
||||
assert status == 404
|
||||
assert body["code"] == "source_not_found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_cls", [AgentDriveManifestApi, AgentDriveCommitApi])
|
||||
@pytest.mark.parametrize("api_cls", [AgentDriveManifestApi, AgentDriveSkillsApi, AgentDriveCommitApi])
|
||||
def test_endpoints_have_handlers(api_cls):
|
||||
assert callable(getattr(api_cls(), "get", None) or getattr(api_cls(), "post", None))
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""Unit tests for the inner knowledge retrieval controller."""
|
||||
"""Unit tests for the plugin inner knowledge retrieval controller."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@ -53,31 +55,38 @@ def _payload() -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _plugin_inner_auth() -> Iterator[None]:
|
||||
with (
|
||||
patch("configs.dify_config.PLUGIN_DAEMON_KEY", "plugin-daemon-key"),
|
||||
patch("configs.dify_config.INNER_API_KEY_FOR_PLUGIN", "inner-key"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_401_when_api_key_missing(self, inner_api_app: Flask):
|
||||
with patch("configs.dify_config.INNER_API", True):
|
||||
def test_post_returns_404_when_api_key_missing(self, inner_api_app: Flask):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
headers=_headers(api_key=None),
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()["code"] == "inner_api_unauthorized"
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_post_returns_401_when_api_key_invalid(self, inner_api_app: Flask):
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
def test_post_returns_404_when_api_key_invalid(self, inner_api_app: Flask):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
headers=_headers(api_key="wrong-key"),
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()["code"] == "inner_api_unauthorized"
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_post_returns_400_for_invalid_body(self, inner_api_app: Flask):
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json={"caller": {"tenant_id": "tenant-1"}},
|
||||
@ -91,7 +100,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_404_for_service_not_found_error(self, mock_retrieve, inner_api_app: Flask):
|
||||
mock_retrieve.side_effect = InnerKnowledgeRetrieveAppNotFoundError("app missing")
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
@ -105,7 +114,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_403_for_service_forbidden_error(self, mock_retrieve, inner_api_app: Flask):
|
||||
mock_retrieve.side_effect = InnerKnowledgeRetrieveDatasetTenantMismatchError("wrong tenant")
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
@ -119,7 +128,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_422_for_retrieval_config_value_error(self, mock_retrieve, inner_api_app: Flask):
|
||||
mock_retrieve.side_effect = ValueError("invalid reranking config")
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
@ -133,7 +142,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_429_for_rate_limit_error(self, mock_retrieve, inner_api_app: Flask):
|
||||
mock_retrieve.side_effect = RateLimitExceededError("knowledge rate limited")
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
@ -147,7 +156,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
payload = _payload()
|
||||
payload["metadata_filtering"] = {"mode": "manual"}
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=payload,
|
||||
@ -161,7 +170,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
payload = _payload()
|
||||
payload["metadata_filtering"] = {"mode": "automatic"}
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=payload,
|
||||
@ -175,7 +184,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_502_for_external_knowledge_failure(self, mock_retrieve, inner_api_app: Flask):
|
||||
mock_retrieve.side_effect = ExternalKnowledgeRetrievalError("upstream failed")
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
@ -219,7 +228,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
),
|
||||
)
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
|
||||
@ -226,19 +226,8 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
|
||||
|
||||
def _soul_with_model_and_skill() -> AgentSoulConfig:
|
||||
from models.agent_config_entities import AgentSkillRefConfig
|
||||
|
||||
soul = _soul_with_model()
|
||||
soul.skills_files.skills = [
|
||||
AgentSkillRefConfig.model_validate(
|
||||
{
|
||||
"id": "abc",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
}
|
||||
)
|
||||
]
|
||||
soul.prompt.system_prompt = "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"
|
||||
return soul
|
||||
|
||||
|
||||
@ -247,6 +236,28 @@ class TestAgentAppDriveLayer:
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"size": 1,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
|
||||
{"key": "tender-analyzer/SKILL.md", "is_skill": True}
|
||||
],
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
@ -256,12 +267,42 @@ class TestAgentAppDriveLayer:
|
||||
|
||||
drive = next(layer for layer in result.request.composition.layers if layer.name == "drive")
|
||||
assert drive.type == "dify.drive"
|
||||
assert drive.deps == {"execution_context": "execution_context"}
|
||||
assert drive.config.drive_ref == "agent-agent-1"
|
||||
assert [skill.skill_md_key for skill in drive.config.skills] == ["tender-analyzer/SKILL.md"]
|
||||
assert drive.config.mentioned_skill_keys == ["tender-analyzer/SKILL.md"]
|
||||
# injected right after execution_context, mirroring the workflow surface
|
||||
names = [layer.name for layer in result.request.composition.layers]
|
||||
assert names.index("drive") == names.index("execution_context") + 1
|
||||
|
||||
def test_drive_layer_injected_with_empty_catalog_and_shell_depends_on_it(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(_soul_with_model()))
|
||||
|
||||
layers = {layer.name: layer for layer in result.request.composition.layers}
|
||||
assert layers["drive"].config.drive_ref == "agent-agent-1"
|
||||
assert layers["drive"].config.skills == []
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": "execution_context",
|
||||
"drive": "drive",
|
||||
}
|
||||
|
||||
def test_no_drive_layer_when_flag_disabled(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
@ -269,3 +310,152 @@ class TestAgentAppDriveLayer:
|
||||
)
|
||||
result = builder.build(_ctx(_soul_with_model_and_skill()))
|
||||
assert all(layer.name != "drive" for layer in result.request.composition.layers)
|
||||
|
||||
def test_agent_app_runtime_expands_skill_and_file_mentions_in_agent_soul_prompt(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"size": 1,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
|
||||
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
|
||||
{"key": "files/sample.pdf", "is_skill": False},
|
||||
],
|
||||
)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = (
|
||||
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(soul))
|
||||
|
||||
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert prompt_layer.config.prefix == "Use Tender Analyzer and sample.pdf."
|
||||
assert "[§" not in prompt_layer.config.prefix
|
||||
|
||||
def test_agent_app_runtime_missing_drive_mentions_fall_back_to_label_then_decoded_key(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = (
|
||||
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
|
||||
"and [§file:files%2Fmissing.txt§]."
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(soul))
|
||||
|
||||
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and files/missing.txt."
|
||||
assert "[§" not in prompt_layer.config.prefix
|
||||
|
||||
def test_agent_app_runtime_expands_drive_mentions_in_agent_soul_prompt(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"size": 1,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
|
||||
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
|
||||
{"key": "files/sample.pdf", "is_skill": False},
|
||||
],
|
||||
)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = (
|
||||
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]"
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(soul))
|
||||
|
||||
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert prompt_layer.config.prefix == "Use Tender Analyzer and sample.pdf"
|
||||
assert "[§" not in prompt_layer.config.prefix
|
||||
|
||||
def test_agent_app_runtime_missing_drive_mentions_fall_back_without_marker_leak(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = (
|
||||
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
|
||||
"and [§file:files%2Fno-label.txt§]."
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(soul))
|
||||
|
||||
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and files/no-label.txt."
|
||||
assert "[§" not in prompt_layer.config.prefix
|
||||
|
||||
@ -834,57 +834,116 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
|
||||
|
||||
def _soul_with_drive_skill() -> AgentSoulConfig:
|
||||
return AgentSoulConfig(
|
||||
prompt={"system_prompt": "You are careful."},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
skills_files={
|
||||
"skills": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
},
|
||||
{"id": "legacy", "name": "Legacy Skill"}, # pre-standardization: no drive key
|
||||
],
|
||||
"files": [
|
||||
{"name": "sample.pdf", "drive_key": "files/sample.pdf", "type": "application/pdf"},
|
||||
{"name": "plain-upload.pdf", "file_id": "upload-1"}, # not drive-backed
|
||||
],
|
||||
prompt={
|
||||
"system_prompt": (
|
||||
"You are careful. Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] "
|
||||
"and [§file:files%2Fsample.pdf:sample.pdf§]."
|
||||
)
|
||||
},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
)
|
||||
|
||||
|
||||
def test_build_drive_layer_config_catalogs_only_drive_backed_refs():
|
||||
def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"size": 123,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": "hash-1",
|
||||
"created_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
|
||||
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
|
||||
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False},
|
||||
{"key": "files/sample.pdf", "is_skill": False},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _mock_empty_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
|
||||
|
||||
def test_build_drive_layer_config_catalogs_drive_skills_and_mentions(monkeypatch: pytest.MonkeyPatch):
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
|
||||
|
||||
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), agent_id="agent-1")
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
assert config is not None
|
||||
assert config.drive_ref == "agent-agent-1"
|
||||
assert [skill.skill_md_key for skill in config.skills] == ["tender-analyzer/SKILL.md"]
|
||||
assert config.skills[0].archive_key == "tender-analyzer/.DIFY-SKILL-FULL.zip"
|
||||
assert [file.key for file in config.files] == ["files/sample.pdf"]
|
||||
assert [w["code"] for w in warnings] == ["skill_ref_dangling"]
|
||||
assert "Legacy Skill" in warnings[0]["message"]
|
||||
assert config.mentioned_skill_keys == ["tender-analyzer/SKILL.md"]
|
||||
assert config.mentioned_file_keys == ["files/sample.pdf"]
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_build_drive_layer_config_skips_when_nothing_configured():
|
||||
def test_build_drive_layer_config_emits_drive_ref_when_catalog_is_empty(monkeypatch: pytest.MonkeyPatch):
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
|
||||
|
||||
_mock_empty_drive_catalog(monkeypatch)
|
||||
soul = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test")
|
||||
)
|
||||
assert build_drive_layer_config(soul, agent_id="agent-1") == (None, [])
|
||||
config, warnings = build_drive_layer_config(soul, tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
assert config is not None
|
||||
assert config.drive_ref == "agent-agent-1"
|
||||
assert config.skills == []
|
||||
assert config.mentioned_skill_keys == []
|
||||
assert config.mentioned_file_keys == []
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_workflow_run_request_contains_drive_layer_with_empty_catalog(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True)
|
||||
_mock_empty_drive_catalog(monkeypatch)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context())
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
|
||||
assert layers["drive"]["config"] == {
|
||||
"drive_ref": "agent-agent-1",
|
||||
"skills": [],
|
||||
"mentioned_skill_keys": [],
|
||||
"mentioned_file_keys": [],
|
||||
}
|
||||
assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"drive": "drive",
|
||||
}
|
||||
|
||||
|
||||
def test_build_drive_layer_config_requires_agent_identity():
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
|
||||
|
||||
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), agent_id=None)
|
||||
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), tenant_id="tenant-1", agent_id=None)
|
||||
|
||||
assert config is None
|
||||
assert [w["code"] for w in warnings] == ["skill_ref_dangling"]
|
||||
assert [w["code"] for w in warnings] == ["drive_ref_dangling"]
|
||||
|
||||
|
||||
def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch):
|
||||
@ -892,6 +951,7 @@ def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = _soul_with_drive_skill()
|
||||
|
||||
@ -904,21 +964,21 @@ def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch
|
||||
assert layer_names.index("drive") == layer_names.index("execution_context") + 1
|
||||
drive = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "drive")
|
||||
assert drive["type"] == "dify.drive"
|
||||
assert drive["deps"] == {"execution_context": "execution_context"}
|
||||
assert drive["config"]["drive_ref"] == "agent-agent-1"
|
||||
assert drive["config"]["skills"] == [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
}
|
||||
]
|
||||
assert drive["config"]["files"] == [
|
||||
{"name": "sample.pdf", "key": "files/sample.pdf", "size": None, "mime_type": "application/pdf"}
|
||||
]
|
||||
# the dangling legacy ref degraded to a warning instead of failing the run
|
||||
assert drive["config"]["mentioned_skill_keys"] == ["tender-analyzer/SKILL.md"]
|
||||
assert drive["config"]["mentioned_file_keys"] == ["files/sample.pdf"]
|
||||
warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"]
|
||||
assert any(w["code"] == "skill_ref_dangling" for w in warnings)
|
||||
assert warnings == []
|
||||
# the drive layer is non-sensitive and must survive into persistable specs
|
||||
from dify_agent.protocol import extract_runtime_layer_specs
|
||||
|
||||
@ -926,6 +986,51 @@ def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch
|
||||
assert any(spec.name == "drive" and spec.type == "dify.drive" for spec in specs)
|
||||
|
||||
|
||||
def test_workflow_runtime_expands_drive_mentions_in_agent_soul_prompt(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = _soul_with_drive_skill()
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert soul_prompt.config.prefix == "You are careful. Use Tender Analyzer and sample.pdf."
|
||||
assert "[§" not in soul_prompt.config.prefix
|
||||
|
||||
|
||||
def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded_key(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = AgentSoulConfig(
|
||||
prompt={
|
||||
"system_prompt": (
|
||||
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
|
||||
"and [§file:files%2Fno-label.txt§]."
|
||||
)
|
||||
},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and files/no-label.txt."
|
||||
assert "[§" not in soul_prompt.config.prefix
|
||||
|
||||
|
||||
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled():
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = _soul_with_drive_skill()
|
||||
@ -934,20 +1039,20 @@ def test_workflow_run_request_has_no_drive_layer_when_flag_disabled():
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
assert all(layer["name"] != "drive" for layer in dumped["composition"]["layers"])
|
||||
warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"]
|
||||
assert any(w["code"] == "drive_manifest_disabled" for w in warnings)
|
||||
assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == []
|
||||
|
||||
|
||||
def test_build_drive_layer_config_all_refs_dangling_yields_no_config():
|
||||
def test_build_drive_layer_config_missing_mentions_warn_but_keep_skill_catalog(monkeypatch: pytest.MonkeyPatch):
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
|
||||
|
||||
_mock_drive_catalog(monkeypatch)
|
||||
soul = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u1"}]},
|
||||
prompt={"system_prompt": "Use [§skill:ghost%2FSKILL.md:Ghost§]"},
|
||||
)
|
||||
config, warnings = build_drive_layer_config(soul, agent_id="agent-1")
|
||||
assert config is None
|
||||
assert [w["code"] for w in warnings] == ["skill_ref_dangling"]
|
||||
config, warnings = build_drive_layer_config(soul, tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert config is not None
|
||||
assert [w["code"] for w in warnings] == ["mention_target_missing"]
|
||||
|
||||
|
||||
# ── ENG-635: ask_human layer gating + feature manifest ───────────────────────
|
||||
|
||||
@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
_MIGRATION_PATH = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_migration_module():
|
||||
spec = importlib.util.spec_from_file_location("agent_drive_skill_metadata_refactor", _MIGRATION_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("failed to load migration module")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _create_pre_upgrade_schema(engine: sa.Engine) -> None:
|
||||
metadata = sa.MetaData()
|
||||
sa.Table(
|
||||
"agent_drive_files",
|
||||
metadata,
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("agent_id", sa.String(36), nullable=False),
|
||||
sa.Column("key", sa.String(512), nullable=False),
|
||||
sa.Column("file_kind", sa.String(32), nullable=False),
|
||||
sa.Column("file_id", sa.String(36), nullable=False),
|
||||
sa.Column("value_owned_by_drive", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("size", sa.BigInteger(), nullable=True),
|
||||
sa.Column("hash", sa.String(255), nullable=True),
|
||||
sa.Column("mime_type", sa.String(255), nullable=True),
|
||||
sa.Column("created_by", sa.String(36), nullable=True),
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
|
||||
)
|
||||
sa.Table(
|
||||
"agent_config_snapshots",
|
||||
metadata,
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("config_snapshot", sa.Text(), nullable=False),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
|
||||
def _run_migration_step(module: object, engine: sa.Engine, step_name: str) -> None:
|
||||
with engine.begin() as connection:
|
||||
context = MigrationContext.configure(connection)
|
||||
operations = Operations(context)
|
||||
original_op = module.op
|
||||
module.op = operations
|
||||
try:
|
||||
getattr(module, step_name)()
|
||||
finally:
|
||||
module.op = original_op
|
||||
|
||||
|
||||
def test_upgrade_adds_skill_columns_and_index_and_strips_snapshot_data() -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
_create_pre_upgrade_schema(engine)
|
||||
snapshot = {
|
||||
"prompt": {"system_prompt": "Use [§skill:legacy:Legacy§]"},
|
||||
"skills_files": {"skills": [{"name": "Legacy"}], "files": [{"name": "u.pdf"}]},
|
||||
}
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
sa.text("INSERT INTO agent_config_snapshots (id, config_snapshot) VALUES (:id, :config_snapshot)"),
|
||||
{"id": "snap-1", "config_snapshot": json.dumps(snapshot)},
|
||||
)
|
||||
|
||||
module = _load_migration_module()
|
||||
_run_migration_step(module, engine, "upgrade")
|
||||
|
||||
inspector = sa.inspect(engine)
|
||||
columns = {column["name"] for column in inspector.get_columns("agent_drive_files")}
|
||||
assert {"is_skill", "skill_metadata"}.issubset(columns)
|
||||
indexes = {index["name"] for index in inspector.get_indexes("agent_drive_files")}
|
||||
assert "agent_drive_files_tenant_agent_is_skill_key_idx" in indexes
|
||||
|
||||
with engine.begin() as connection:
|
||||
stored_snapshot = connection.execute(
|
||||
sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"),
|
||||
{"id": "snap-1"},
|
||||
).scalar_one()
|
||||
assert "skills_files" not in json.loads(stored_snapshot)
|
||||
|
||||
|
||||
def test_downgrade_drops_skill_columns_and_index_without_reconstructing_legacy_data() -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
_create_pre_upgrade_schema(engine)
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
sa.text("INSERT INTO agent_config_snapshots (id, config_snapshot) VALUES (:id, :config_snapshot)"),
|
||||
{"id": "snap-1", "config_snapshot": json.dumps({"prompt": {"system_prompt": "hello"}})},
|
||||
)
|
||||
|
||||
module = _load_migration_module()
|
||||
_run_migration_step(module, engine, "upgrade")
|
||||
_run_migration_step(module, engine, "downgrade")
|
||||
|
||||
inspector = sa.inspect(engine)
|
||||
columns = {column["name"] for column in inspector.get_columns("agent_drive_files")}
|
||||
assert "is_skill" not in columns
|
||||
assert "skill_metadata" not in columns
|
||||
indexes = {index["name"] for index in inspector.get_indexes("agent_drive_files")}
|
||||
assert "agent_drive_files_tenant_agent_is_skill_key_idx" not in indexes
|
||||
|
||||
with engine.begin() as connection:
|
||||
stored_snapshot = connection.execute(
|
||||
sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"),
|
||||
{"id": "snap-1"},
|
||||
).scalar_one()
|
||||
assert "skills_files" not in json.loads(stored_snapshot)
|
||||
@ -17,7 +17,6 @@ from models.agent import (
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentFileRefConfig,
|
||||
DeclaredArrayItem,
|
||||
DeclaredOutputChildConfig,
|
||||
DeclaredOutputConfig,
|
||||
@ -2649,18 +2648,17 @@ def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatc
|
||||
assert {entry["granularity"] for entry in entries[1:]} == {"tool"}
|
||||
|
||||
|
||||
# ── ENG-623 §4.4: drive-backed ref validation ────────────────────────────────
|
||||
# ── ENG-623 §4.4: drive-backed prompt mention validation ─────────────────────
|
||||
|
||||
|
||||
def _drive_soul(**overrides):
|
||||
from services.entities.agent_entities import AgentSoulConfig
|
||||
|
||||
base = {
|
||||
"skills_files": {
|
||||
"skills": [
|
||||
{"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"},
|
||||
],
|
||||
"files": [{"name": "sample.pdf", "drive_key": "files/sample.pdf"}],
|
||||
"prompt": {
|
||||
"system_prompt": (
|
||||
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
|
||||
)
|
||||
},
|
||||
}
|
||||
base.update(overrides)
|
||||
@ -2680,47 +2678,47 @@ def _patch_drive_keys(monkeypatch, existing_keys):
|
||||
return captured
|
||||
|
||||
|
||||
def test_drive_ref_findings_reports_missing_keys(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_drive_mention_findings_reports_missing_keys(monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md"])
|
||||
|
||||
findings = AgentComposerService._drive_ref_findings(
|
||||
tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul()
|
||||
findings = AgentComposerService._drive_mention_findings(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=_drive_soul().prompt.system_prompt,
|
||||
)
|
||||
|
||||
assert [(f["code"], f["id"]) for f in findings] == [("file_ref_dangling", "files/sample.pdf")]
|
||||
assert str(findings[0]["message"]).startswith("file_ref_dangling: ")
|
||||
assert [(f["code"], f["id"]) for f in findings] == [("mention_target_missing", "files/sample.pdf")]
|
||||
assert findings[0]["kind"] == "file"
|
||||
assert str(findings[0]["message"]).startswith("file 'sample.pdf' has no drive entry")
|
||||
|
||||
|
||||
def test_drive_ref_findings_clean_when_all_keys_exist(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_drive_mention_findings_clean_when_all_keys_exist(monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md", "files/sample.pdf"])
|
||||
|
||||
assert (
|
||||
AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul())
|
||||
AgentComposerService._drive_mention_findings(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=_drive_soul().prompt.system_prompt,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_drive_ref_findings_skips_refs_without_drive_keys(monkeypatch: pytest.MonkeyPatch):
|
||||
# No drive-backed ref at all -> no DB roundtrip, no findings.
|
||||
soul = _drive_soul(
|
||||
skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u-1"}]}
|
||||
def test_drive_mention_findings_skips_prompt_without_drive_mentions(monkeypatch: pytest.MonkeyPatch):
|
||||
# No drive-backed mention at all -> no DB roundtrip, no findings.
|
||||
soul = _drive_soul(prompt={"system_prompt": "Use [§knowledge:kb-1:Docs§]."})
|
||||
findings = AgentComposerService._drive_mention_findings(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=soul.prompt.system_prompt,
|
||||
)
|
||||
findings = AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=soul)
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_require_drive_refs_resolved_raises_with_stable_code(monkeypatch: pytest.MonkeyPatch):
|
||||
from services.agent.errors import InvalidComposerConfigError
|
||||
|
||||
_patch_drive_keys(monkeypatch, existing_keys=[])
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"):
|
||||
AgentComposerService._require_drive_refs_resolved(
|
||||
tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul()
|
||||
)
|
||||
|
||||
|
||||
def test_collect_validation_findings_appends_drive_findings_with_agent_context(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_collect_validation_findings_appends_drive_mention_findings_with_agent_context(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
|
||||
_patch_drive_keys(monkeypatch, existing_keys=[])
|
||||
@ -2737,149 +2735,14 @@ def test_collect_validation_findings_appends_drive_findings_with_agent_context(m
|
||||
)
|
||||
|
||||
codes = {w["code"] for w in findings["warnings"]}
|
||||
assert {"skill_ref_dangling", "file_ref_dangling"} <= codes
|
||||
assert codes >= {"mention_target_missing"}
|
||||
assert {w["id"] for w in findings["warnings"] if w["code"] == "mention_target_missing"} == {
|
||||
"tender-analyzer/SKILL.md",
|
||||
"files/sample.pdf",
|
||||
}
|
||||
# without agent context the drive check is skipped entirely
|
||||
findings_no_agent = AgentComposerService.collect_validation_findings(tenant_id="tenant-1", payload=payload)
|
||||
assert all(w["code"] not in {"skill_ref_dangling", "file_ref_dangling"} for w in findings_no_agent["warnings"])
|
||||
|
||||
|
||||
# ── ENG-625 D5: soul-first ref removal ───────────────────────────────────────
|
||||
|
||||
|
||||
def _patch_remove_drive_refs_env(monkeypatch: pytest.MonkeyPatch, *, soul_dict):
|
||||
"""Wire the classmethod's collaborators so soul editing + versioning is observable."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import services.agent.composer_service as module
|
||||
|
||||
agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", updated_by=None)
|
||||
snapshot = SimpleNamespace(id="snap-1", tenant_id="tenant-1", agent_id="agent-1", config_snapshot_dict=soul_dict)
|
||||
committed: dict[str, object] = {}
|
||||
|
||||
fake_session = SimpleNamespace(scalar=lambda stmt: agent, commit=lambda: committed.setdefault("committed", True))
|
||||
monkeypatch.setattr(module.db, "session", fake_session)
|
||||
monkeypatch.setattr(AgentComposerService, "_require_version", classmethod(lambda cls, **kwargs: snapshot))
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_update(cls, *, current_snapshot, account_id, agent_soul, operation, version_note):
|
||||
captured["agent_soul"] = agent_soul
|
||||
captured["version_note"] = version_note
|
||||
return SimpleNamespace(id="snap-2")
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_update_current_version", classmethod(fake_update))
|
||||
return agent, captured, committed
|
||||
|
||||
|
||||
def test_remove_drive_refs_drops_skill_by_slug_and_versions(monkeypatch: pytest.MonkeyPatch):
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [
|
||||
{"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"},
|
||||
{"id": "sk-2", "name": "Other", "skill_md_key": "other-skill/SKILL.md"},
|
||||
],
|
||||
"files": [],
|
||||
}
|
||||
}
|
||||
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", skill_slug="tender-analyzer"
|
||||
)
|
||||
|
||||
assert version_id == "snap-2"
|
||||
assert agent.active_config_snapshot_id == "snap-2"
|
||||
kept = [s.skill_md_key for s in captured["agent_soul"].skills_files.skills]
|
||||
assert kept == ["other-skill/SKILL.md"]
|
||||
assert "Tender Analyzer" in str(captured["version_note"])
|
||||
assert committed.get("committed") is True
|
||||
|
||||
|
||||
def test_remove_drive_refs_is_noop_when_ref_absent(monkeypatch: pytest.MonkeyPatch):
|
||||
soul_dict = {"skills_files": {"skills": [], "files": []}}
|
||||
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
assert (
|
||||
AgentComposerService.remove_drive_refs(
|
||||
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/none.pdf"
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert "agent_soul" not in captured
|
||||
assert committed == {}
|
||||
|
||||
|
||||
def test_remove_drive_refs_drops_file_by_key(monkeypatch: pytest.MonkeyPatch):
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [],
|
||||
"files": [
|
||||
{"name": "keep.pdf", "drive_key": "files/keep.pdf"},
|
||||
{"name": "drop.pdf", "drive_key": "files/drop.pdf"},
|
||||
],
|
||||
}
|
||||
}
|
||||
_, captured, _ = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/drop.pdf"
|
||||
)
|
||||
|
||||
assert version_id == "snap-2"
|
||||
assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/keep.pdf"]
|
||||
|
||||
|
||||
def test_add_drive_file_ref_adds_or_replaces_file_and_versions(monkeypatch: pytest.MonkeyPatch):
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [],
|
||||
"files": [
|
||||
{"name": "old.pdf", "drive_key": "files/old.pdf"},
|
||||
{"name": "stale.pdf", "drive_key": "files/new.pdf"},
|
||||
],
|
||||
}
|
||||
}
|
||||
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
version_id = AgentComposerService.add_drive_file_ref(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="acc-1",
|
||||
file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf", type="application/pdf"),
|
||||
)
|
||||
|
||||
assert version_id == "snap-2"
|
||||
assert agent.active_config_snapshot_id == "snap-2"
|
||||
assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/old.pdf", "files/new.pdf"]
|
||||
assert captured["agent_soul"].skills_files.files[-1].name == "new.pdf"
|
||||
assert "new.pdf" in str(captured["version_note"])
|
||||
assert committed.get("committed") is True
|
||||
|
||||
|
||||
def test_add_drive_file_ref_syncs_workflow_binding_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="snap-1", updated_by=None)
|
||||
_patch_remove_drive_refs_env(monkeypatch, soul_dict={"skills_files": {"skills": [], "files": []}})
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
|
||||
|
||||
AgentComposerService.add_drive_file_ref(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="acc-1",
|
||||
file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf"),
|
||||
app_id="app-1",
|
||||
node_id="agent-node-1",
|
||||
)
|
||||
|
||||
assert binding.current_snapshot_id == "snap-2"
|
||||
assert binding.updated_by == "acc-1"
|
||||
|
||||
|
||||
def test_remove_drive_refs_requires_exactly_one_scope():
|
||||
with pytest.raises(ValueError):
|
||||
AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u")
|
||||
assert all(w["code"] != "mention_target_missing" for w in findings_no_agent["warnings"])
|
||||
|
||||
|
||||
# ── ENG-623/625: resolver helpers + save-path drive guard ────────────────────
|
||||
@ -2917,58 +2780,7 @@ def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(mon
|
||||
assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") == "agent-7"
|
||||
|
||||
|
||||
def test_remove_drive_refs_returns_none_without_agent_or_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import services.agent.composer_service as module
|
||||
|
||||
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: None))
|
||||
assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None
|
||||
|
||||
agent_without_snapshot = SimpleNamespace(id="a", active_config_snapshot_id=None)
|
||||
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: agent_without_snapshot))
|
||||
assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None
|
||||
|
||||
|
||||
def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies(monkeypatch: pytest.MonkeyPatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
"save_strategy": "save_to_current_version",
|
||||
"agent_soul": _drive_soul().model_dump(mode="json"),
|
||||
"soul_lock": {"locked": False},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_workflow_binding",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-1")),
|
||||
)
|
||||
guarded: dict[str, str] = {}
|
||||
|
||||
def fake_guard(cls, *, tenant_id, agent_id, agent_soul):
|
||||
guarded["agent_id"] = agent_id
|
||||
raise InvalidComposerConfigError("skill_ref_dangling: boom")
|
||||
|
||||
from services.agent.errors import InvalidComposerConfigError
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard))
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"):
|
||||
AgentComposerService.save_workflow_composer(
|
||||
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
|
||||
)
|
||||
assert guarded["agent_id"] == "agent-1"
|
||||
|
||||
|
||||
def test_save_workflow_composer_guards_drive_refs_for_inline_node_job_only(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_save_workflow_composer_reports_drive_mentions_for_inline_node_job_only(monkeypatch: pytest.MonkeyPatch):
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
@ -3006,26 +2818,27 @@ def test_save_workflow_composer_guards_drive_refs_for_inline_node_job_only(monke
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []})
|
||||
)
|
||||
guarded: dict[str, str] = {}
|
||||
|
||||
def fake_guard(cls, *, tenant_id, agent_id, agent_soul):
|
||||
def fake_collect(cls, *, tenant_id, payload, agent_id=None):
|
||||
guarded["tenant_id"] = tenant_id
|
||||
guarded["agent_id"] = agent_id
|
||||
return {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]}
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard))
|
||||
monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect))
|
||||
|
||||
result = AgentComposerService.save_workflow_composer(
|
||||
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
|
||||
)
|
||||
|
||||
assert result == {"state": "ok", "validation": {"warnings": []}}
|
||||
assert result == {
|
||||
"state": "ok",
|
||||
"validation": {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]},
|
||||
}
|
||||
assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"}
|
||||
|
||||
|
||||
def test_save_workflow_composer_skips_drive_refs_for_roster_node_job_only(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_save_workflow_composer_reports_drive_mentions_for_roster_node_job_only(monkeypatch: pytest.MonkeyPatch):
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
@ -3063,29 +2876,17 @@ def test_save_workflow_composer_skips_drive_refs_for_roster_node_job_only(monkey
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []})
|
||||
)
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def fail_guard(cls, *, tenant_id, agent_id, agent_soul):
|
||||
raise AssertionError("roster node-job-only saves must not validate agent drive refs")
|
||||
def fake_collect(cls, *, tenant_id, payload, agent_id=None):
|
||||
captured["agent_id"] = agent_id
|
||||
return {"warnings": []}
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fail_guard))
|
||||
monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect))
|
||||
|
||||
result = AgentComposerService.save_workflow_composer(
|
||||
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
|
||||
)
|
||||
|
||||
assert result == {"state": "ok", "validation": {"warnings": []}}
|
||||
|
||||
|
||||
def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch: pytest.MonkeyPatch):
|
||||
soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}}
|
||||
_, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
assert (
|
||||
AgentComposerService.remove_drive_refs(
|
||||
tenant_id="t-1", agent_id="agent-1", account_id="acc-1", skill_slug="ghost"
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert committed == {}
|
||||
assert captured["agent_id"] == "agent-1"
|
||||
|
||||
@ -118,10 +118,6 @@ def test_previous_outputs_capped_and_flagged():
|
||||
def _soul() -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"skills_files": {
|
||||
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
|
||||
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
|
||||
},
|
||||
"tools": {
|
||||
"cli_tools": [
|
||||
{"id": "ct-1", "name": "ffmpeg"},
|
||||
@ -144,7 +140,6 @@ def test_soul_candidates_lists_configured_items_only():
|
||||
)
|
||||
|
||||
assert truncated is False
|
||||
assert [item["kind"] for item in lists["skills_files"]] == ["skill", "file"]
|
||||
assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"]
|
||||
# the stable mention id flows through so the frontend can mint [§cli_tool:<id>§]
|
||||
assert [item["id"] for item in lists["cli_tools"]] == ["ct-1"]
|
||||
@ -158,35 +153,19 @@ def test_soul_candidates_lists_configured_items_only():
|
||||
assert lists["dify_tools"][0]["id"] == "tavily/tavily_search"
|
||||
|
||||
|
||||
def test_candidates_response_preserves_skill_and_file_candidate_shapes():
|
||||
def test_candidates_response_omits_legacy_skill_file_candidates():
|
||||
response = AgentComposerCandidatesResponse.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"allowed_node_job_candidates": {},
|
||||
"allowed_soul_candidates": {
|
||||
"skills_files": [
|
||||
{"kind": "skill", "id": "sk-1", "name": "tender-analyzer", "path": "skills/tender.md"},
|
||||
{
|
||||
"kind": "file",
|
||||
"id": "f-1",
|
||||
"name": "qna_report.pdf",
|
||||
"transfer_method": "local_file",
|
||||
"reference": "upload-1",
|
||||
"url": "https://files.example/qna_report.pdf",
|
||||
},
|
||||
]
|
||||
"cli_tools": [],
|
||||
},
|
||||
"capabilities": {"human_roster_available": False},
|
||||
}
|
||||
).model_dump(mode="json")
|
||||
|
||||
skill, file = response["allowed_soul_candidates"]["skills_files"]
|
||||
assert skill["kind"] == "skill"
|
||||
assert skill["path"] == "skills/tender.md"
|
||||
assert file["kind"] == "file"
|
||||
assert file["transfer_method"] == "local_file"
|
||||
assert file["reference"] == "upload-1"
|
||||
assert file["url"] == "https://files.example/qna_report.pdf"
|
||||
assert "skills_files" not in response["allowed_soul_candidates"]
|
||||
|
||||
|
||||
def test_soul_candidates_empty_config_yields_empty_lists():
|
||||
|
||||
@ -171,7 +171,6 @@ def test_configured_but_deleted_dataset_surfaces_as_placeholder():
|
||||
def test_unresolved_non_knowledge_mentions_warn_target_missing():
|
||||
findings = _findings(_soul_payload("use [§skill:nope:Ghost Skill§] and [§human:missing§]"))
|
||||
codes = [(w["code"], w["kind"]) for w in findings["warnings"]]
|
||||
assert ("mention_target_missing", "skill") in codes
|
||||
assert ("mention_target_missing", "human") in codes
|
||||
assert findings["knowledge_retrieval_placeholder"] == []
|
||||
|
||||
|
||||
@ -7,11 +7,13 @@ guarantees no mention-shaped marker survives to the model.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
|
||||
from services.agent.prompt_mentions import (
|
||||
MAX_MENTION_FIELD_LENGTH,
|
||||
MAX_MENTION_REF_ID_LENGTH,
|
||||
NODE_JOB_PROMPT_ALLOWED_KINDS,
|
||||
SOUL_PROMPT_ALLOWED_KINDS,
|
||||
MentionKind,
|
||||
@ -26,11 +28,11 @@ from services.agent.prompt_mentions import (
|
||||
|
||||
|
||||
def test_parse_extracts_kind_id_and_optional_label():
|
||||
prompt = "Use [§skill:abc-1:tender-analyzer§] then ask [§human:c-1§]."
|
||||
prompt = "Use [§skill:tender-analyzer%2FSKILL.md:tender-analyzer§] then ask [§human:c-1§]."
|
||||
mentions = parse_prompt_mentions(prompt)
|
||||
|
||||
assert [(m.kind, m.ref_id, m.label) for m in mentions] == [
|
||||
(MentionKind.SKILL, "abc-1", "tender-analyzer"),
|
||||
(MentionKind.SKILL, "tender-analyzer%2FSKILL.md", "tender-analyzer"),
|
||||
(MentionKind.HUMAN, "c-1", None),
|
||||
]
|
||||
assert prompt[mentions[0].start : mentions[0].end] == mentions[0].raw
|
||||
@ -48,10 +50,16 @@ def test_parse_ignores_legacy_template_forms_and_unknown_kinds():
|
||||
|
||||
|
||||
def test_parse_skips_oversized_id_or_label():
|
||||
long_id = "x" * (MAX_MENTION_FIELD_LENGTH + 1)
|
||||
long_id = "x" * (MAX_MENTION_REF_ID_LENGTH + 1)
|
||||
assert parse_prompt_mentions(f"[§skill:{long_id}§]") == []
|
||||
|
||||
|
||||
def test_parse_accepts_long_unicode_encoded_drive_key_within_drive_limit():
|
||||
encoded_drive_key = quote("你" * 512)
|
||||
mentions = parse_prompt_mentions(f"[§skill:{encoded_drive_key}:Long Skill§]")
|
||||
assert [(mention.kind, mention.ref_id) for mention in mentions] == [(MentionKind.SKILL, encoded_drive_key)]
|
||||
|
||||
|
||||
# ── expand + scrub ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -88,10 +96,6 @@ def test_expand_empty_prompt_is_noop():
|
||||
def soul() -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"skills_files": {
|
||||
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
|
||||
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
|
||||
},
|
||||
"tools": {
|
||||
"dify_tools": [
|
||||
{
|
||||
@ -112,17 +116,13 @@ def soul() -> AgentSoulConfig:
|
||||
def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
|
||||
resolver = build_soul_mention_resolver(soul)
|
||||
prompt = (
|
||||
"Use [§skill:sk-1§] with [§file:f-1§], search via "
|
||||
"[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], "
|
||||
"Use [§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], "
|
||||
"ground in [§knowledge:ds-1§], ask [§human:c-1§]."
|
||||
)
|
||||
|
||||
expanded = expand_prompt_mentions(prompt, resolver)
|
||||
|
||||
assert expanded == (
|
||||
"Use tender-analyzer with qna_report.pdf, search via tavily_search, "
|
||||
"run ffmpeg, ground in 产品手册, ask EMAIL · David Hayes."
|
||||
)
|
||||
assert expanded == ("Use tavily_search, run ffmpeg, ground in 产品手册, ask EMAIL · David Hayes.")
|
||||
|
||||
|
||||
def test_soul_resolver_unknown_ids_degrade(soul: AgentSoulConfig):
|
||||
|
||||
@ -121,16 +121,3 @@ def test_read_member_bytes_roundtrip_and_errors():
|
||||
with pytest.raises(SkillPackageError) as bad_zip:
|
||||
service.read_member_bytes(content=b"not a zip", member_path="SKILL.md")
|
||||
assert bad_zip.value.code == "invalid_archive"
|
||||
|
||||
|
||||
def test_to_skill_ref_carries_metadata():
|
||||
manifest = _extract({"SKILL.md": _SKILL_MD.encode()})
|
||||
ref = manifest.to_skill_ref(file_id="upload-1", path="pdf-toolkit/.DIFY-SKILL-FULL.zip")
|
||||
|
||||
assert ref.name == "PDF Toolkit"
|
||||
assert ref.file_id == "upload-1"
|
||||
assert ref.path == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
|
||||
assert ref.id == manifest.hash
|
||||
dumped = ref.model_dump()
|
||||
assert dumped["hash"] == manifest.hash
|
||||
assert dumped["entry_path"] == "SKILL.md"
|
||||
|
||||
@ -43,6 +43,19 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members()
|
||||
]
|
||||
drive = MagicMock()
|
||||
drive.commit.return_value = []
|
||||
drive.list_skills.return_value = [
|
||||
{
|
||||
"path": "pdf-toolkit",
|
||||
"skill_md_key": "pdf-toolkit/SKILL.md",
|
||||
"archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
"name": "PDF Toolkit",
|
||||
"description": "Work with PDFs.",
|
||||
"size": len(_SKILL_MD),
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": None,
|
||||
},
|
||||
]
|
||||
|
||||
service = SkillStandardizeService(tool_file_manager=tool_files, drive_service=drive)
|
||||
result = service.standardize(
|
||||
@ -76,18 +89,16 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members()
|
||||
assert all(item.value_owned_by_drive for item in items)
|
||||
assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file", "script-tool-file"]
|
||||
assert items[0].is_skill is True
|
||||
assert items[0].skill_metadata is not None
|
||||
assert items[0].skill_metadata.name == "PDF Toolkit"
|
||||
assert items[0].skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
|
||||
assert items[1].is_skill is False
|
||||
assert items[2].is_skill is False
|
||||
|
||||
# The returned skill ref carries stable drive paths + file ids.
|
||||
# The returned upload response carries only the drive-derived fields the UI needs.
|
||||
skill = result["skill"]
|
||||
assert skill["path"] == "pdf-toolkit"
|
||||
assert skill["name"] == "PDF Toolkit"
|
||||
assert skill["full_archive_file_id"] == "zip-tool-file"
|
||||
assert skill["skill_md_file_id"] == "md-tool-file"
|
||||
assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
|
||||
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
|
||||
# ENG-371: zip member listing persisted for infer-tools signals
|
||||
assert "SKILL.md" in skill["manifest_files"]
|
||||
assert "scripts/run.py" in skill["manifest_files"]
|
||||
assert result["manifest"]["files"] == ["SKILL.md", "scripts/run.py"]
|
||||
|
||||
@ -29,13 +29,8 @@ def _service(preview=_SKILL_MD_PREVIEW):
|
||||
return SkillToolInferenceService(drive_service=drive), drive
|
||||
|
||||
|
||||
def _patch_soul_files(monkeypatch, files):
|
||||
monkeypatch.setattr(SkillToolInferenceService, "_manifest_files_from_soul", staticmethod(lambda **kwargs: files))
|
||||
|
||||
|
||||
def test_infer_returns_suggestions_with_inferred_from(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_infer_returns_suggestions_with_inferred_from(monkeypatch):
|
||||
service, drive = _service()
|
||||
_patch_soul_files(monkeypatch, ["SKILL.md", "scripts/transcribe.sh"])
|
||||
raw = (
|
||||
'{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",'
|
||||
' "description": "transcoding for step 2", "command": "ffmpeg",'
|
||||
@ -53,9 +48,8 @@ def test_infer_returns_suggestions_with_inferred_from(monkeypatch: pytest.Monkey
|
||||
drive.preview.assert_called_once_with(tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md")
|
||||
|
||||
|
||||
def test_infer_threads_manifest_files_into_the_prompt(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_infer_threads_skill_md_into_the_prompt(monkeypatch):
|
||||
service, _ = _service()
|
||||
_patch_soul_files(monkeypatch, ["scripts/run.sh"])
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_invoke(*, tenant_id, user_prompt):
|
||||
@ -65,22 +59,20 @@ def test_infer_threads_manifest_files_into_the_prompt(monkeypatch: pytest.Monkey
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(fake_invoke)):
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe")
|
||||
|
||||
assert "scripts/run.sh" in captured["prompt"]
|
||||
assert "Files inside the skill package" not in captured["prompt"]
|
||||
assert "ffmpeg" in captured["prompt"] # SKILL.md body present
|
||||
|
||||
|
||||
def test_infer_not_inferable_passes_reason_through(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_infer_not_inferable_passes_reason_through(monkeypatch):
|
||||
service, _ = _service()
|
||||
_patch_soul_files(monkeypatch, [])
|
||||
raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}'
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe")
|
||||
assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}
|
||||
|
||||
|
||||
def test_infer_retries_once_then_422(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_infer_retries_once_then_422(monkeypatch):
|
||||
service, _ = _service()
|
||||
_patch_soul_files(monkeypatch, [])
|
||||
calls: list[int] = []
|
||||
|
||||
def bad_invoke(**kwargs):
|
||||
@ -96,9 +88,8 @@ def test_infer_retries_once_then_422(monkeypatch: pytest.MonkeyPatch):
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
|
||||
def test_infer_repairs_slightly_malformed_json(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_infer_repairs_slightly_malformed_json(monkeypatch):
|
||||
service, _ = _service()
|
||||
_patch_soul_files(monkeypatch, [])
|
||||
raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}'
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe")
|
||||
@ -123,10 +114,10 @@ def test_binary_skill_md_maps_to_404():
|
||||
assert exc_info.value.code == "skill_not_found"
|
||||
|
||||
|
||||
# ── real-path coverage: _invoke / _manifest_files_from_soul / passthrough ────
|
||||
# ── real-path coverage: _invoke / passthrough ────────────────────────────────
|
||||
|
||||
|
||||
def test_invoke_maps_missing_default_model_to_400(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_invoke_maps_missing_default_model_to_400(monkeypatch):
|
||||
import services.agent.skill_tool_inference_service as module
|
||||
from core.errors.error import ProviderTokenNotInitError
|
||||
|
||||
@ -140,7 +131,7 @@ def test_invoke_maps_missing_default_model_to_400(monkeypatch: pytest.MonkeyPatc
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch):
|
||||
import services.agent.skill_tool_inference_service as module
|
||||
|
||||
fake_manager = MagicMock()
|
||||
@ -171,55 +162,3 @@ def test_load_skill_md_passes_through_non_missing_drive_errors():
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="x")
|
||||
assert exc_info.value.code == "agent_not_found"
|
||||
|
||||
|
||||
def _patch_inference_db(monkeypatch: pytest.MonkeyPatch, *, agent, snapshot):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import services.agent.skill_tool_inference_service as module
|
||||
|
||||
results = iter([agent, snapshot])
|
||||
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: next(results)))
|
||||
|
||||
|
||||
def test_manifest_files_from_soul_reads_active_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [
|
||||
{"name": "Other", "skill_md_key": "other/SKILL.md", "manifest_files": ["x.md"]},
|
||||
{"name": "Audio", "skill_md_key": "audio-transcribe/SKILL.md", "manifest_files": ["scripts/a.sh"]},
|
||||
]
|
||||
}
|
||||
}
|
||||
agent = SimpleNamespace(active_config_snapshot_id="snap-1")
|
||||
snapshot = SimpleNamespace(config_snapshot_dict=soul_dict)
|
||||
_patch_inference_db(monkeypatch, agent=agent, snapshot=snapshot)
|
||||
|
||||
files = SkillToolInferenceService._manifest_files_from_soul(
|
||||
tenant_id="t-1", agent_id="a-1", slug="audio-transcribe"
|
||||
)
|
||||
assert files == ["scripts/a.sh"]
|
||||
|
||||
|
||||
def test_manifest_files_from_soul_degrades_when_agent_or_snapshot_missing(monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_inference_db(monkeypatch, agent=None, snapshot=None)
|
||||
assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == []
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
_patch_inference_db(monkeypatch, agent=SimpleNamespace(active_config_snapshot_id="snap-1"), snapshot=None)
|
||||
assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == []
|
||||
|
||||
|
||||
def test_manifest_files_from_soul_empty_when_slug_not_in_soul(monkeypatch: pytest.MonkeyPatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}]}}
|
||||
_patch_inference_db(
|
||||
monkeypatch,
|
||||
agent=SimpleNamespace(active_config_snapshot_id="snap-1"),
|
||||
snapshot=SimpleNamespace(config_snapshot_dict=soul_dict),
|
||||
)
|
||||
assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="ghost") == []
|
||||
|
||||
@ -15,7 +15,7 @@ from sqlalchemy import delete, select
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.agent import Agent, AgentDriveFile, AgentScope, AgentSource
|
||||
from models.agent import Agent, AgentDriveFile, AgentDriveFileKind, AgentScope, AgentSource
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import UploadFile
|
||||
from models.tools import ToolFile
|
||||
@ -133,6 +133,123 @@ def test_commit_then_manifest_lists_the_entry():
|
||||
assert AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, prefix="other/") == []
|
||||
|
||||
|
||||
def test_commit_skill_row_persists_metadata_and_lists_catalog() -> None:
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="tender-analyzer/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description="Parses RFPs."),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "tender-analyzer/SKILL.md"))
|
||||
assert row is not None
|
||||
assert row.is_skill is True
|
||||
assert row.skill_metadata == '{"description":"Parses RFPs.","name":"Tender Analyzer"}'
|
||||
|
||||
skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT)
|
||||
assert len(skills) == 1
|
||||
assert skills[0]["path"] == "tender-analyzer"
|
||||
assert skills[0]["skill_md_key"] == "tender-analyzer/SKILL.md"
|
||||
assert skills[0]["archive_key"] is None
|
||||
assert skills[0]["name"] == "Tender Analyzer"
|
||||
assert skills[0]["description"] == "Parses RFPs."
|
||||
assert skills[0]["size"] == 5
|
||||
assert skills[0]["mime_type"] == "text/plain"
|
||||
|
||||
|
||||
def test_commit_rejects_skill_row_without_skill_metadata() -> None:
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="tender-analyzer/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
is_skill=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "invalid_skill_metadata"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw_metadata", [None, '{"description":"oops"}'])
|
||||
def test_list_skills_raises_controlled_error_for_invalid_stored_metadata(raw_metadata: str | None) -> None:
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
session.add(
|
||||
AgentDriveFile(
|
||||
id="44444444-4444-4444-4444-444444444444",
|
||||
tenant_id=TENANT,
|
||||
agent_id=AGENT,
|
||||
key="broken-skill/SKILL.md",
|
||||
file_kind=AgentDriveFileKind.TOOL_FILE,
|
||||
file_id=tf,
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata=raw_metadata,
|
||||
size=5,
|
||||
mime_type="text/plain",
|
||||
created_by=USER,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT)
|
||||
|
||||
assert exc_info.value.code == "invalid_skill_metadata"
|
||||
|
||||
|
||||
def test_commit_rejects_non_skill_row_with_skill_metadata() -> None:
|
||||
tf = _seed_tool_file()
|
||||
with pytest.raises(AgentDriveError, match="skill metadata"):
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="files/report.txt",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
skill_metadata=DriveSkillMetadata(name="Bad", description=""),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_commit_rejects_non_canonical_skill_key() -> None:
|
||||
tf = _seed_tool_file(name="README.md")
|
||||
with pytest.raises(AgentDriveError, match="canonical"):
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="tender-analyzer/README.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description=""),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_commit_rejects_tool_file_not_owned_by_user():
|
||||
other = _seed_tool_file(user_id="99999999-9999-9999-9999-999999999999")
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
@ -247,6 +364,49 @@ def test_recommit_same_value_is_idempotent_and_keeps_value():
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_recommit_same_skill_value_updates_metadata_without_cleaning_backing_file() -> None:
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="tender-analyzer/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description="v1"),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="tender-analyzer/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
value_owned_by_drive=False,
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="Tender Analyzer v2", description="v2"),
|
||||
)
|
||||
],
|
||||
)
|
||||
storage_mock.delete.assert_not_called()
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "tender-analyzer/SKILL.md"))
|
||||
assert row is not None
|
||||
assert row.file_id == tf
|
||||
assert row.value_owned_by_drive is False
|
||||
assert row.skill_metadata == '{"description":"v2","name":"Tender Analyzer v2"}'
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
|
||||
|
||||
|
||||
def _seed_upload_file(*, name: str = "u.txt") -> str:
|
||||
upload = UploadFile(
|
||||
tenant_id=TENANT,
|
||||
@ -319,7 +479,7 @@ def test_manifest_includes_internal_download_url():
|
||||
|
||||
with (
|
||||
patch("services.agent_drive_service.file_factory.build_from_mapping", return_value=object()),
|
||||
patch("services.agent_drive_service.DifyWorkflowFileRuntime") as runtime_cls,
|
||||
patch("core.app.workflow.file_runtime.DifyWorkflowFileRuntime") as runtime_cls,
|
||||
):
|
||||
runtime_cls.return_value.resolve_file_url.return_value = "http://internal/files/x?sign=1"
|
||||
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, include_download_url=True)
|
||||
@ -349,16 +509,31 @@ def test_delete_by_key_cleans_drive_owned_value():
|
||||
_commit("files/doomed.txt", tf, owned=True)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/doomed.txt")
|
||||
removed = AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[DriveCommitItem(key="files/doomed.txt", file_ref=None)],
|
||||
)
|
||||
storage_mock.delete.assert_called_once()
|
||||
|
||||
assert removed == ["files/doomed.txt"]
|
||||
assert removed == [
|
||||
{
|
||||
"key": "files/doomed.txt",
|
||||
"file_kind": "tool_file",
|
||||
"file_id": tf,
|
||||
"value_owned_by_drive": True,
|
||||
"is_skill": False,
|
||||
"skill_metadata": None,
|
||||
"removed": True,
|
||||
}
|
||||
]
|
||||
with session_factory.create_session() as session:
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is None
|
||||
assert list(session.scalars(select(AgentDriveFile))) == []
|
||||
|
||||
|
||||
def test_delete_by_prefix_removes_all_skill_keys():
|
||||
def test_commit_null_batch_removes_multiple_skill_keys():
|
||||
md = _seed_tool_file(name="SKILL.md")
|
||||
zf = _seed_tool_file(name="full.zip")
|
||||
_commit("tender-analyzer/SKILL.md", md, owned=True)
|
||||
@ -367,9 +542,20 @@ def test_delete_by_prefix_removes_all_skill_keys():
|
||||
_commit("files/other.txt", other, owned=True)
|
||||
|
||||
with patch("services.agent_drive_service.storage"):
|
||||
removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="tender-analyzer/")
|
||||
removed = AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(key="tender-analyzer/SKILL.md", file_ref=None),
|
||||
DriveCommitItem(key="tender-analyzer/.DIFY-SKILL-FULL.zip", file_ref=None),
|
||||
],
|
||||
)
|
||||
|
||||
assert sorted(removed) == ["tender-analyzer/.DIFY-SKILL-FULL.zip", "tender-analyzer/SKILL.md"]
|
||||
assert sorted(item["key"] for item in removed) == [
|
||||
"tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"tender-analyzer/SKILL.md",
|
||||
]
|
||||
with session_factory.create_session() as session:
|
||||
# both skill ToolFiles physically removed, the unrelated file untouched
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == md)) is None
|
||||
@ -379,28 +565,30 @@ def test_delete_by_prefix_removes_all_skill_keys():
|
||||
assert keys == ["files/other.txt"]
|
||||
|
||||
|
||||
def test_delete_is_idempotent():
|
||||
assert AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/never-there.txt") == []
|
||||
assert AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="ghost-skill/") == []
|
||||
def test_commit_null_is_idempotent_for_missing_keys():
|
||||
removed = AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[DriveCommitItem(key="files/never-there.txt", file_ref=None)],
|
||||
)
|
||||
assert removed == [{"key": "files/never-there.txt", "removed": True, "noop": True}]
|
||||
|
||||
|
||||
def test_delete_requires_exactly_one_scope():
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT)
|
||||
assert exc_info.value.code == "invalid_delete_scope"
|
||||
with pytest.raises(AgentDriveError):
|
||||
AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="a/", key="a/b")
|
||||
|
||||
|
||||
def test_delete_keeps_shared_value_records():
|
||||
def test_commit_null_keeps_shared_value_records():
|
||||
tf = _seed_tool_file(name="shared.txt")
|
||||
_commit("files/shared.txt", tf, owned=False)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/shared.txt")
|
||||
removed = AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[DriveCommitItem(key="files/shared.txt", file_ref=None)],
|
||||
)
|
||||
storage_mock.delete.assert_not_called()
|
||||
|
||||
assert removed == ["files/shared.txt"]
|
||||
assert removed[0]["key"] == "files/shared.txt"
|
||||
with session_factory.create_session() as session:
|
||||
# only the KV row dropped; the shared ToolFile survives
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
|
||||
@ -502,7 +690,7 @@ def test_upload_file_download_url_uses_attachment_filename():
|
||||
upload_file_id = _seed_upload_file(name="report.pdf")
|
||||
_commit_upload("files/report.pdf", upload_file_id)
|
||||
|
||||
with patch("services.agent_drive_service.DifyWorkflowFileRuntime") as runtime_cls:
|
||||
with patch("core.app.workflow.file_runtime.DifyWorkflowFileRuntime") as runtime_cls:
|
||||
runtime_cls.return_value.resolve_upload_file_url.return_value = "https://files.example/report.pdf"
|
||||
url = AgentDriveService().download_url(tenant_id=TENANT, agent_id=AGENT, key="files/report.pdf")
|
||||
|
||||
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@ -1304,7 +1304,7 @@ requires-dist = [
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.2.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.2.1" },
|
||||
{ name = "typer", specifier = ">=0.16.1,<0.17" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
|
||||
|
||||
59
dify-agent/docker/local-sandbox/Dockerfile
Normal file
59
dify-agent/docker/local-sandbox/Dockerfile
Normal file
@ -0,0 +1,59 @@
|
||||
# Local sandbox image for shellctl-managed Dify Agent workspaces.
|
||||
#
|
||||
# Build this from the dify-agent package root:
|
||||
# docker build -f docker/local-sandbox/Dockerfile -t dify-agent-local-sandbox:local .
|
||||
#
|
||||
# This image merges the former shellctl-only image with the sandbox-visible
|
||||
# Agent Stub client CLI. It runs shellctl by default, and shellctl-managed jobs
|
||||
# can call `dify-agent ...` without installing extra packages at runtime.
|
||||
|
||||
FROM python:3.12-slim-bookworm AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
DIFY_AGENT_STUB_DRIVE_BASE=/mnt/drive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tmux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV UV_VERSION=0.8.9
|
||||
RUN python -m pip install --no-cache-dir "uv==${UV_VERSION}"
|
||||
|
||||
WORKDIR /opt/dify-agent
|
||||
|
||||
|
||||
FROM base AS packages
|
||||
|
||||
ENV SHELL_SESSION_MANAGER_VERSION=2.2.1
|
||||
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
COPY src ./src
|
||||
|
||||
RUN uv sync --frozen --no-dev --no-editable --extra grpc \
|
||||
&& uv pip install --python .venv/bin/python "shell-session-manager==${SHELL_SESSION_MANAGER_VERSION}"
|
||||
|
||||
|
||||
FROM base AS production
|
||||
|
||||
ENV VIRTUAL_ENV=/opt/dify-agent/.venv
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
|
||||
COPY --from=packages ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
RUN ln -s ${VIRTUAL_ENV}/bin/dify-agent /usr/local/bin/dify-agent \
|
||||
&& ln -s ${VIRTUAL_ENV}/bin/shellctl /usr/local/bin/shellctl \
|
||||
&& useradd --create-home --shell /bin/sh dify \
|
||||
&& mkdir -p /mnt/drive \
|
||||
&& chown -R dify:dify /home/dify /mnt/drive
|
||||
|
||||
USER dify
|
||||
WORKDIR /home/dify
|
||||
|
||||
EXPOSE 5004
|
||||
|
||||
CMD ["shellctl", "serve", "--listen", "0.0.0.0:5004"]
|
||||
@ -1,25 +0,0 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tmux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m pip install --no-cache-dir \
|
||||
shell-session-manager==2.2.0 \
|
||||
uv
|
||||
|
||||
RUN useradd --create-home --shell /bin/sh dify
|
||||
|
||||
USER dify
|
||||
WORKDIR /home/dify
|
||||
|
||||
EXPOSE 5004
|
||||
|
||||
CMD ["shellctl", "serve", "--listen", "0.0.0.0:5004"]
|
||||
@ -51,6 +51,9 @@ DIFY_AGENT_REDIS_PREFIX=dify-agent
|
||||
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-plugin-daemon-server-key
|
||||
|
||||
DIFY_AGENT_INNER_API_URL=http://localhost:5001
|
||||
DIFY_AGENT_INNER_API_KEY=replace-with-dify-inner-api-key-for-plugin
|
||||
EOF
|
||||
```
|
||||
|
||||
@ -63,12 +66,16 @@ The minimum settings are:
|
||||
- `DIFY_AGENT_PLUGIN_DAEMON_API_KEY`: API key sent by the server to the plugin
|
||||
daemon. In a Dify Docker setup this is usually the value previously configured
|
||||
as `PLUGIN_DAEMON_KEY`.
|
||||
- `DIFY_AGENT_INNER_API_URL`: Dify API service root for `/inner/api/...` calls.
|
||||
- `DIFY_AGENT_INNER_API_KEY`: API key sent to Dify API inner plugin endpoints.
|
||||
In Docker this should match `PLUGIN_DIFY_INNER_API_KEY`, which maps to Dify
|
||||
API `INNER_API_KEY_FOR_PLUGIN`.
|
||||
|
||||
See `.example.env` for the full server settings template.
|
||||
|
||||
If you plan to run `dify.shell`, also configure `DIFY_AGENT_SHELLCTL_ENTRYPOINT`
|
||||
and, when shell jobs need to call back with the `dify-agent` command, set
|
||||
`DIFY_AGENT_STUB_URL` plus a 32-byte base64url
|
||||
`DIFY_AGENT_STUB_API_BASE_URL` plus a 32-byte base64url
|
||||
`DIFY_AGENT_SERVER_SECRET_KEY` as documented in `.example.env`.
|
||||
|
||||
## Start the Dify Agent server
|
||||
|
||||
@ -36,11 +36,13 @@ also reads `.env` and `dify-agent/.env` when present.
|
||||
| `DIFY_AGENT_RUN_RETENTION_SECONDS` | `259200` | Seconds to retain Redis run records and per-run event streams; defaults to 3 days. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_URL` | `http://localhost:5002` | Base URL for the Dify plugin daemon. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_API_KEY` | empty | API key sent to the Dify plugin daemon. |
|
||||
| `DIFY_AGENT_INNER_API_URL` | `http://localhost:5001` | Dify API service root used when dify-agent calls `/inner/api/...` endpoints. |
|
||||
| `DIFY_AGENT_INNER_API_KEY` | empty | API key sent to Dify API inner plugin endpoints. Set this to Dify API `INNER_API_KEY_FOR_PLUGIN` (Docker: `PLUGIN_DIFY_INNER_API_KEY`). |
|
||||
| `DIFY_AGENT_SHELLCTL_ENTRYPOINT` | empty | Base URL for the shellctl server used by `dify.shell`; required when runs include the shell layer. |
|
||||
| `DIFY_AGENT_SHELLCTL_AUTH_TOKEN` | empty | Optional bearer token sent to the shellctl server. |
|
||||
| `DIFY_AGENT_STUB_URL` | empty | Public Agent Stub URL reachable from shellctl-managed remote machines. Use `http(s)://.../agent-stub` for HTTP or `grpc://host:port` for gRPC; enables `DIFY_AGENT_STUB_*` env injection for user `shell.run` jobs. |
|
||||
| `DIFY_AGENT_STUB_GRPC_BIND_ADDRESS` | empty | Optional `host:port` bind override used only when `DIFY_AGENT_STUB_URL` uses `grpc://`. |
|
||||
| `DIFY_AGENT_SERVER_SECRET_KEY` | empty | Server-wide root secret used to derive Agent Stub JWE keys; required when `DIFY_AGENT_STUB_URL` is set and must be unpadded base64url for 32 bytes. |
|
||||
| `DIFY_AGENT_STUB_API_BASE_URL` | empty | Public Agent Stub API base URL reachable from shellctl-managed remote machines. HTTP may be the service root or `/agent-stub`; gRPC must be `grpc://host:port`. Enables `DIFY_AGENT_STUB_*` env injection for user `shell.run` jobs. |
|
||||
| `DIFY_AGENT_STUB_GRPC_BIND_ADDRESS` | empty | Optional `host:port` bind override used only when `DIFY_AGENT_STUB_API_BASE_URL` uses `grpc://`. |
|
||||
| `DIFY_AGENT_SERVER_SECRET_KEY` | empty | Server-wide root secret used to derive Agent Stub JWE keys; required when `DIFY_AGENT_STUB_API_BASE_URL` is set and must be unpadded base64url for 32 bytes. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT` | `10` | Plugin-daemon HTTP connect timeout in seconds. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_READ_TIMEOUT` | `600` | Plugin-daemon HTTP read timeout in seconds. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_WRITE_TIMEOUT` | `30` | Plugin-daemon HTTP write timeout in seconds. |
|
||||
@ -58,10 +60,12 @@ DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-daemon-key
|
||||
DIFY_AGENT_INNER_API_URL=http://localhost:5001
|
||||
DIFY_AGENT_INNER_API_KEY=replace-with-dify-inner-api-key-for-plugin
|
||||
DIFY_AGENT_SHELLCTL_ENTRYPOINT=http://127.0.0.1:5004
|
||||
DIFY_AGENT_SHELLCTL_AUTH_TOKEN=replace-with-shellctl-token
|
||||
# Generate with: python -c 'import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode())'
|
||||
DIFY_AGENT_STUB_URL=https://agent.example.com/agent-stub
|
||||
DIFY_AGENT_STUB_API_BASE_URL=https://agent.example.com/agent-stub
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=replace-with-base64url-32-byte-secret
|
||||
```
|
||||
|
||||
|
||||
@ -55,10 +55,14 @@ To let commands inside user-visible shell jobs call back to the Dify Agent serve
|
||||
with `dify-agent ...`, also enable the Agent Stub:
|
||||
|
||||
```env
|
||||
DIFY_AGENT_STUB_URL=https://agent.example.com/agent-stub
|
||||
DIFY_AGENT_STUB_API_BASE_URL=https://agent.example.com/agent-stub
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=replace-with-base64url-32-byte-secret
|
||||
```
|
||||
|
||||
HTTP `DIFY_AGENT_STUB_API_BASE_URL` may be either the service root or the
|
||||
explicit `/agent-stub` API root; the server normalizes the service root to
|
||||
`/agent-stub`. Other HTTP paths are rejected at startup.
|
||||
|
||||
`DIFY_AGENT_SERVER_SECRET_KEY` must be unpadded base64url text for exactly 32
|
||||
decoded bytes. One way to generate it is:
|
||||
|
||||
@ -69,10 +73,14 @@ python -c 'import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_
|
||||
## Client request shape
|
||||
|
||||
A client adds the shell layer as an ordinary composition layer. Basic shell jobs
|
||||
do not need dependencies. To inject `DIFY_AGENT_STUB_URL` and
|
||||
`DIFY_AGENT_STUB_AUTH_JWE` into user-visible `shell.run` jobs, declare the
|
||||
execution-context layer as the shell layer's `execution_context` dependency. A
|
||||
typical run still also includes:
|
||||
do not need dependencies. To inject `DIFY_AGENT_STUB_API_BASE_URL`,
|
||||
`DIFY_AGENT_STUB_AUTH_JWE`, and `DIFY_AGENT_STUB_DRIVE_BASE` into user-visible
|
||||
`shell.run` jobs, declare the execution-context layer as the shell layer's
|
||||
`execution_context` dependency. When the run also includes `dify.drive`, declare
|
||||
it as the shell layer's `drive` dependency; the injected drive base is then
|
||||
computed from the fixed Agent Stub drive mount and the drive reference, for
|
||||
example `/mnt/drive/agent-123`. Without a drive dependency, the CLI keeps the
|
||||
historical `/mnt/drive` fallback. A typical run still also includes:
|
||||
|
||||
- a prompt layer that supplies the task;
|
||||
- an execution-context layer carrying tenant/user context;
|
||||
@ -194,33 +202,34 @@ Here is the analysis of the sales dataset:
|
||||
* **SHA-256 Hash:** `e86521a0d759037a09b059cb3cb2419f0a3f06e674db8151ccf2f93811dac0b8`
|
||||
````
|
||||
|
||||
## Running shellctl in Docker
|
||||
## Running the local sandbox in Docker
|
||||
|
||||
Build the shellctl image from the Dify Agent package root:
|
||||
Build the local sandbox image from the Dify Agent package root:
|
||||
|
||||
```bash
|
||||
docker build -f docker/shellctl/Dockerfile -t dify-agent-shellctl:local .
|
||||
docker build -f docker/local-sandbox/Dockerfile -t dify-agent-local-sandbox:local .
|
||||
```
|
||||
|
||||
Run it with a bearer token and publish the API on localhost:
|
||||
|
||||
```bash
|
||||
docker run --rm --name dify-agent-shellctl \
|
||||
docker run --rm --name dify-agent-local-sandbox \
|
||||
-e SHELLCTL_AUTH_TOKEN=replace-with-a-token \
|
||||
-p 127.0.0.1:5004:5004 \
|
||||
dify-agent-shellctl:local
|
||||
dify-agent-local-sandbox:local
|
||||
```
|
||||
|
||||
The image starts `shellctl serve --listen 0.0.0.0:5004` as the non-root
|
||||
`dify` user and leaves shellctl state/runtime directories at their package
|
||||
defaults.
|
||||
`dify` user. It also sets the fallback `DIFY_AGENT_STUB_DRIVE_BASE=/mnt/drive`
|
||||
and pre-creates that directory with write access for the same user.
|
||||
|
||||
## Docker image contents
|
||||
|
||||
The provided `docker/shellctl/Dockerfile` installs:
|
||||
The provided `docker/local-sandbox/Dockerfile` installs:
|
||||
|
||||
- `tmux`, required by `shellctl` to manage shell jobs;
|
||||
- `shell-session-manager==2.2.0`, which provides the `shellctl` CLI/server;
|
||||
- `shell-session-manager==2.2.1`, which provides the `shellctl` CLI/server;
|
||||
- `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell
|
||||
workspace;
|
||||
- the `dify-agent` Agent Stub client CLI, including its gRPC transport extra;
|
||||
- a non-root default user named `dify`.
|
||||
|
||||
@ -26,7 +26,7 @@ server = [
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"redis>=7.4.0,<8.0.0",
|
||||
"shell-session-manager==2.2.0",
|
||||
"shell-session-manager==2.2.1",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
]
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ ToolFile ids back into the drive.
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from tempfile import TemporaryDirectory
|
||||
@ -32,6 +33,7 @@ from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubDriveFileRef,
|
||||
AgentStubDriveItem,
|
||||
AgentStubDriveManifestResponse,
|
||||
DEFAULT_AGENT_STUB_DRIVE_BASE,
|
||||
)
|
||||
|
||||
_SKILL_MD_FILENAME = "SKILL.md"
|
||||
@ -81,11 +83,15 @@ def list_drive_from_environment(prefix: str, json_output: bool) -> str | AgentSt
|
||||
return _format_manifest(response)
|
||||
|
||||
|
||||
def pull_drive_from_environment(prefix: str, drive_base: str = "/mnt/drive") -> list[Path]:
|
||||
def pull_drive_from_environment(
|
||||
targets: list[str] | None = None,
|
||||
drive_base: str = DEFAULT_AGENT_STUB_DRIVE_BASE,
|
||||
) -> list[Path]:
|
||||
"""Pull drive files into one local drive base via signed download URLs.
|
||||
|
||||
Args:
|
||||
prefix: Optional drive-key prefix forwarded to the manifest request.
|
||||
targets: Optional drive-key targets or prefixes. An empty list preserves
|
||||
the historical whole-drive pull by using ``[""]``.
|
||||
drive_base: Local base directory that receives downloaded drive files.
|
||||
|
||||
Returns:
|
||||
@ -117,16 +123,24 @@ def pull_drive_from_environment(prefix: str, drive_base: str = "/mnt/drive") ->
|
||||
"""
|
||||
|
||||
environment = read_agent_stub_environment()
|
||||
response = request_agent_stub_drive_manifest_sync(
|
||||
url=environment.url,
|
||||
auth_jwe=environment.auth_jwe,
|
||||
prefix=prefix,
|
||||
include_download_url=True,
|
||||
)
|
||||
manifest_targets = targets or [""]
|
||||
with ThreadPoolExecutor(max_workers=min(len(manifest_targets), 4)) as executor:
|
||||
responses = list(
|
||||
executor.map(
|
||||
lambda target: request_agent_stub_drive_manifest_sync(
|
||||
url=environment.url,
|
||||
auth_jwe=environment.auth_jwe,
|
||||
prefix=target,
|
||||
include_download_url=True,
|
||||
),
|
||||
manifest_targets,
|
||||
)
|
||||
)
|
||||
base_path = Path(drive_base).expanduser().resolve()
|
||||
base_path.mkdir(parents=True, exist_ok=True)
|
||||
written_paths: list[Path] = []
|
||||
for item in response.items:
|
||||
deduplicated_items = {item.key: item for response in responses for item in response.items}
|
||||
for item in [deduplicated_items[key] for key in sorted(deduplicated_items)]:
|
||||
download_url = item.download_url
|
||||
if not isinstance(download_url, str) or not download_url:
|
||||
raise AgentStubValidationError(f"drive manifest item is missing download_url: {item.key}")
|
||||
|
||||
@ -8,8 +8,10 @@ import os
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_URL_ENV_VAR,
|
||||
normalize_agent_stub_url,
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR,
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR,
|
||||
DEFAULT_AGENT_STUB_DRIVE_BASE,
|
||||
normalize_agent_stub_api_base_url,
|
||||
)
|
||||
|
||||
|
||||
@ -28,32 +30,44 @@ class AgentStubEnvironment:
|
||||
def has_agent_stub_environment(env: Mapping[str, str] | None = None) -> bool:
|
||||
"""Return whether both required Agent Stub environment variables exist."""
|
||||
values = env or os.environ
|
||||
return bool(values.get(AGENT_STUB_URL_ENV_VAR) and values.get(AGENT_STUB_AUTH_JWE_ENV_VAR))
|
||||
return bool(values.get(AGENT_STUB_API_BASE_URL_ENV_VAR) and values.get(AGENT_STUB_AUTH_JWE_ENV_VAR))
|
||||
|
||||
|
||||
def read_agent_stub_environment(env: Mapping[str, str] | None = None) -> AgentStubEnvironment:
|
||||
"""Read and validate the Agent Stub environment variables."""
|
||||
values = env or os.environ
|
||||
url = (values.get(AGENT_STUB_URL_ENV_VAR) or "").strip()
|
||||
url = (values.get(AGENT_STUB_API_BASE_URL_ENV_VAR) or "").strip()
|
||||
auth_jwe = (values.get(AGENT_STUB_AUTH_JWE_ENV_VAR) or "").strip()
|
||||
missing: list[str] = []
|
||||
if not url:
|
||||
missing.append(AGENT_STUB_URL_ENV_VAR)
|
||||
missing.append(AGENT_STUB_API_BASE_URL_ENV_VAR)
|
||||
if not auth_jwe:
|
||||
missing.append(AGENT_STUB_AUTH_JWE_ENV_VAR)
|
||||
if missing:
|
||||
names = ", ".join(missing)
|
||||
raise MissingAgentStubEnvironmentError(f"missing required Agent Stub environment variables: {names}")
|
||||
try:
|
||||
normalized_url = normalize_agent_stub_url(url)
|
||||
normalized_url = normalize_agent_stub_api_base_url(url)
|
||||
except ValueError as exc:
|
||||
raise MissingAgentStubEnvironmentError(f"invalid {AGENT_STUB_URL_ENV_VAR}: {exc}") from exc
|
||||
raise MissingAgentStubEnvironmentError(f"invalid {AGENT_STUB_API_BASE_URL_ENV_VAR}: {exc}") from exc
|
||||
return AgentStubEnvironment(url=normalized_url, auth_jwe=auth_jwe)
|
||||
|
||||
|
||||
def read_agent_stub_drive_base(env: Mapping[str, str] | None = None) -> str:
|
||||
"""Read the sandbox-local drive base used by ``dify-agent drive pull``.
|
||||
|
||||
The variable is optional because older Agent Stub environments only injected
|
||||
URL/auth values. Blank values keep the historical ``/mnt/drive`` fallback.
|
||||
"""
|
||||
values = env or os.environ
|
||||
configured_drive_base = (values.get(AGENT_STUB_DRIVE_BASE_ENV_VAR) or "").strip()
|
||||
return configured_drive_base or DEFAULT_AGENT_STUB_DRIVE_BASE
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentStubEnvironment",
|
||||
"MissingAgentStubEnvironmentError",
|
||||
"has_agent_stub_environment",
|
||||
"read_agent_stub_drive_base",
|
||||
"read_agent_stub_environment",
|
||||
]
|
||||
|
||||
@ -21,9 +21,14 @@ from dify_agent.agent_stub.cli._drive import (
|
||||
pull_drive_from_environment,
|
||||
push_drive_from_environment,
|
||||
)
|
||||
from dify_agent.agent_stub.cli._env import MissingAgentStubEnvironmentError, has_agent_stub_environment
|
||||
from dify_agent.agent_stub.cli._env import (
|
||||
MissingAgentStubEnvironmentError,
|
||||
has_agent_stub_environment,
|
||||
read_agent_stub_drive_base,
|
||||
)
|
||||
from dify_agent.agent_stub.cli._files import download_file_from_environment, upload_file_from_environment
|
||||
from dify_agent.agent_stub.client._errors import AgentStubClientError
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
@ -78,11 +83,22 @@ def drive_list(
|
||||
|
||||
@drive_app.command("pull")
|
||||
def drive_pull(
|
||||
path_prefix: str = typer.Argument("", metavar="PATH_PREFIX"),
|
||||
drive_base: str = typer.Option("/mnt/drive", "--drive-base", help="Local base directory for pulled drive files."),
|
||||
targets: list[str] = typer.Argument(None, metavar="TARGET"),
|
||||
drive_base: str | None = typer.Option(
|
||||
None,
|
||||
"--drive-base",
|
||||
help=(
|
||||
f"Local base directory for pulled drive files. Defaults to ${AGENT_STUB_DRIVE_BASE_ENV_VAR} "
|
||||
f"or {DEFAULT_AGENT_STUB_DRIVE_BASE}."
|
||||
),
|
||||
),
|
||||
) -> None:
|
||||
"""Pull drive files into one local directory tree."""
|
||||
_run_drive_pull(path_prefix=path_prefix, drive_base=drive_base)
|
||||
"""Pull one or more drive keys/prefixes into one local directory tree.
|
||||
|
||||
Passing no ``TARGET`` preserves the historical whole-drive behavior by
|
||||
pulling from the empty prefix.
|
||||
"""
|
||||
_run_drive_pull(targets=targets, drive_base=drive_base)
|
||||
|
||||
|
||||
@drive_app.command("push")
|
||||
@ -207,9 +223,9 @@ def _run_drive_list(*, path_prefix: str, json_output: bool) -> None:
|
||||
typer.echo(response)
|
||||
|
||||
|
||||
def _run_drive_pull(*, path_prefix: str, drive_base: str) -> None:
|
||||
def _run_drive_pull(*, targets: list[str] | None, drive_base: str | None) -> None:
|
||||
try:
|
||||
response = pull_drive_from_environment(prefix=path_prefix, drive_base=drive_base)
|
||||
response = pull_drive_from_environment(targets=targets, drive_base=drive_base or read_agent_stub_drive_base())
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
from .agent_stub import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR,
|
||||
AGENT_STUB_PROTOCOL_VERSION,
|
||||
AGENT_STUB_URL_ENV_VAR,
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR,
|
||||
DEFAULT_AGENT_STUB_DRIVE_BASE,
|
||||
AgentStubConnectRequest,
|
||||
AgentStubConnectResponse,
|
||||
AgentStubDriveCommitItem,
|
||||
@ -20,19 +22,22 @@ from .agent_stub import (
|
||||
AgentStubFileUploadResponse,
|
||||
AgentStubURLScheme,
|
||||
agent_stub_connections_url,
|
||||
agent_stub_drive_base_for_ref,
|
||||
agent_stub_drive_commit_url,
|
||||
agent_stub_drive_manifest_url,
|
||||
agent_stub_file_download_request_url,
|
||||
agent_stub_file_upload_request_url,
|
||||
is_canonical_dify_file_reference,
|
||||
normalize_agent_stub_url,
|
||||
normalize_agent_stub_api_base_url,
|
||||
parse_agent_stub_endpoint,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AGENT_STUB_AUTH_JWE_ENV_VAR",
|
||||
"AGENT_STUB_DRIVE_BASE_ENV_VAR",
|
||||
"AGENT_STUB_PROTOCOL_VERSION",
|
||||
"AGENT_STUB_URL_ENV_VAR",
|
||||
"AGENT_STUB_API_BASE_URL_ENV_VAR",
|
||||
"DEFAULT_AGENT_STUB_DRIVE_BASE",
|
||||
"AgentStubConnectRequest",
|
||||
"AgentStubConnectResponse",
|
||||
"AgentStubDriveCommitItem",
|
||||
@ -49,11 +54,12 @@ __all__ = [
|
||||
"AgentStubFileUploadResponse",
|
||||
"AgentStubURLScheme",
|
||||
"agent_stub_connections_url",
|
||||
"agent_stub_drive_base_for_ref",
|
||||
"agent_stub_drive_commit_url",
|
||||
"agent_stub_drive_manifest_url",
|
||||
"agent_stub_file_download_request_url",
|
||||
"agent_stub_file_upload_request_url",
|
||||
"is_canonical_dify_file_reference",
|
||||
"normalize_agent_stub_url",
|
||||
"normalize_agent_stub_api_base_url",
|
||||
"parse_agent_stub_endpoint",
|
||||
]
|
||||
|
||||
@ -19,8 +19,10 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
||||
|
||||
|
||||
AGENT_STUB_PROTOCOL_VERSION: Final[int] = 1
|
||||
AGENT_STUB_URL_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_URL"
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_API_BASE_URL"
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_AUTH_JWE"
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_DRIVE_BASE"
|
||||
DEFAULT_AGENT_STUB_DRIVE_BASE: Final[str] = "/mnt/drive"
|
||||
|
||||
type AgentStubURLScheme = Literal["http", "https", "grpc"]
|
||||
|
||||
@ -44,14 +46,25 @@ class AgentStubEndpoint:
|
||||
return self.scheme == "grpc"
|
||||
|
||||
|
||||
def agent_stub_drive_base_for_ref(drive_ref: str | None) -> str:
|
||||
"""Return the fixed sandbox-local Agent Stub drive base for one drive ref."""
|
||||
normalized_ref = (drive_ref or "").strip()
|
||||
if not normalized_ref:
|
||||
return DEFAULT_AGENT_STUB_DRIVE_BASE
|
||||
drive_ref_parts = normalized_ref.split("/")
|
||||
if normalized_ref.startswith("/") or any(part in {"", ".", ".."} for part in drive_ref_parts):
|
||||
raise ValueError("Agent Stub drive_ref must be a safe relative path")
|
||||
return f"{DEFAULT_AGENT_STUB_DRIVE_BASE.rstrip('/')}/{'/'.join(drive_ref_parts)}"
|
||||
|
||||
|
||||
def parse_agent_stub_endpoint(url: str) -> AgentStubEndpoint:
|
||||
"""Parse one Agent Stub endpoint URL for HTTP or gRPC transport selection.
|
||||
|
||||
HTTP(S) endpoints are normalized by trimming whitespace and removing a final
|
||||
trailing slash from the path while preserving the configured base path.
|
||||
gRPC endpoints must be plain ``grpc://host:port`` targets with no path,
|
||||
query string, or fragment because transport routing happens on the gRPC
|
||||
service name instead of an HTTP URL path.
|
||||
HTTP(S) endpoints accept either the service root or the explicit
|
||||
``/agent-stub`` API root and normalize to the latter. gRPC endpoints must be
|
||||
plain ``grpc://host:port`` targets with no path, query string, or fragment
|
||||
because transport routing happens on the gRPC service name instead of an
|
||||
HTTP URL path.
|
||||
"""
|
||||
stripped = url.strip()
|
||||
if not stripped:
|
||||
@ -85,6 +98,10 @@ def parse_agent_stub_endpoint(url: str) -> AgentStubEndpoint:
|
||||
)
|
||||
|
||||
normalized_path = parsed.path.rstrip("/")
|
||||
if normalized_path in {"", "/"}:
|
||||
normalized_path = "/agent-stub"
|
||||
elif normalized_path != "/agent-stub":
|
||||
raise ValueError("HTTP Agent Stub API base URL path must be empty or /agent-stub")
|
||||
normalized_url = urlunsplit((scheme, parsed.netloc, normalized_path, "", ""))
|
||||
return AgentStubEndpoint(
|
||||
url=normalized_url,
|
||||
@ -95,8 +112,8 @@ def parse_agent_stub_endpoint(url: str) -> AgentStubEndpoint:
|
||||
)
|
||||
|
||||
|
||||
def normalize_agent_stub_url(url: str) -> str:
|
||||
"""Return the normalized Agent Stub URL used across settings and CLI env."""
|
||||
def normalize_agent_stub_api_base_url(url: str) -> str:
|
||||
"""Return the normalized Agent Stub API base URL used across settings and CLI env."""
|
||||
return parse_agent_stub_endpoint(url).url
|
||||
|
||||
|
||||
@ -233,8 +250,10 @@ class AgentStubDriveCommitItem(BaseModel):
|
||||
"""One drive key to file binding committed through the Agent Stub."""
|
||||
|
||||
key: str
|
||||
file_ref: AgentStubDriveFileRef
|
||||
file_ref: AgentStubDriveFileRef | None = None
|
||||
value_owned_by_drive: bool = True
|
||||
is_skill: bool = False
|
||||
skill_metadata: dict[str, str] | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@ -254,11 +273,14 @@ class AgentStubDriveItem(BaseModel):
|
||||
size: int | None = None
|
||||
hash: str | None = None
|
||||
mime_type: str | None = None
|
||||
file_kind: Literal["upload_file", "tool_file"]
|
||||
file_id: str
|
||||
file_kind: Literal["upload_file", "tool_file"] | None = None
|
||||
file_id: str | None = None
|
||||
created_at: int | None = None
|
||||
download_url: str | None = None
|
||||
value_owned_by_drive: bool | None = None
|
||||
removed: bool | None = None
|
||||
is_skill: bool | None = None
|
||||
skill_metadata: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@ -292,8 +314,10 @@ def _format_url_host(host: str) -> str:
|
||||
|
||||
__all__ = [
|
||||
"AGENT_STUB_AUTH_JWE_ENV_VAR",
|
||||
"AGENT_STUB_DRIVE_BASE_ENV_VAR",
|
||||
"AGENT_STUB_PROTOCOL_VERSION",
|
||||
"AGENT_STUB_URL_ENV_VAR",
|
||||
"AGENT_STUB_API_BASE_URL_ENV_VAR",
|
||||
"DEFAULT_AGENT_STUB_DRIVE_BASE",
|
||||
"AgentStubConnectRequest",
|
||||
"AgentStubConnectResponse",
|
||||
"AgentStubEndpoint",
|
||||
@ -310,11 +334,12 @@ __all__ = [
|
||||
"AgentStubFileUploadResponse",
|
||||
"AgentStubURLScheme",
|
||||
"agent_stub_connections_url",
|
||||
"agent_stub_drive_base_for_ref",
|
||||
"agent_stub_drive_commit_url",
|
||||
"agent_stub_drive_manifest_url",
|
||||
"agent_stub_file_download_request_url",
|
||||
"agent_stub_file_upload_request_url",
|
||||
"is_canonical_dify_file_reference",
|
||||
"normalize_agent_stub_url",
|
||||
"normalize_agent_stub_api_base_url",
|
||||
"parse_agent_stub_endpoint",
|
||||
]
|
||||
|
||||
@ -1,12 +1 @@
|
||||
"""Server-only helpers for running or embedding the Dify Agent Stub server."""
|
||||
|
||||
from .app import app, create_agent_stub_app
|
||||
from .grpc_runtime import start_agent_stub_grpc_server
|
||||
from .router import create_agent_stub_router
|
||||
|
||||
__all__ = [
|
||||
"app",
|
||||
"create_agent_stub_app",
|
||||
"create_agent_stub_router",
|
||||
"start_agent_stub_grpc_server",
|
||||
]
|
||||
|
||||
@ -70,8 +70,8 @@ class DifyApiAgentStubDriveRequestHandler:
|
||||
so this module validates the raw success payload directly.
|
||||
"""
|
||||
|
||||
dify_api_base_url: str
|
||||
dify_api_inner_api_key: str
|
||||
inner_api_url: str
|
||||
inner_api_key: str
|
||||
timeout: httpx.Timeout | float = 30.0
|
||||
|
||||
async def get_manifest(
|
||||
@ -139,13 +139,13 @@ class DifyApiAgentStubDriveRequestHandler:
|
||||
return f"agent-{agent_id}"
|
||||
|
||||
async def _get_inner_api(self, path: str, params: Mapping[str, str]) -> object:
|
||||
url = f"{self.dify_api_base_url.rstrip('/')}{path}"
|
||||
url = f"{self.inner_api_url.rstrip('/')}{path}"
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
url,
|
||||
params=dict(params),
|
||||
headers={"X-Inner-Api-Key": self.dify_api_inner_api_key},
|
||||
headers={"X-Inner-Api-Key": self.inner_api_key},
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AgentStubDriveRequestError(504, "Dify API drive request timed out") from exc
|
||||
@ -154,13 +154,13 @@ class DifyApiAgentStubDriveRequestHandler:
|
||||
return self._normalize_payload(response)
|
||||
|
||||
async def _post_inner_api(self, path: str, payload: Mapping[str, Any]) -> object:
|
||||
url = f"{self.dify_api_base_url.rstrip('/')}{path}"
|
||||
url = f"{self.inner_api_url.rstrip('/')}{path}"
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=dict(payload),
|
||||
headers={"X-Inner-Api-Key": self.dify_api_inner_api_key},
|
||||
headers={"X-Inner-Api-Key": self.inner_api_key},
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AgentStubDriveRequestError(504, "Dify API drive request timed out") from exc
|
||||
|
||||
@ -98,8 +98,8 @@ class DifyApiAgentStubFileRequestHandler:
|
||||
contract without exposing raw ``httpx`` or Pydantic exceptions.
|
||||
"""
|
||||
|
||||
dify_api_base_url: str
|
||||
dify_api_inner_api_key: str
|
||||
inner_api_url: str
|
||||
inner_api_key: str
|
||||
timeout: httpx.Timeout | float = 30.0
|
||||
|
||||
async def create_upload_request(
|
||||
@ -174,13 +174,13 @@ class DifyApiAgentStubFileRequestHandler:
|
||||
return execution_context
|
||||
|
||||
async def _post_inner_api(self, path: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
url = f"{self.dify_api_base_url.rstrip('/')}{path}"
|
||||
url = f"{self.inner_api_url.rstrip('/')}{path}"
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=dict(payload),
|
||||
headers={"X-Inner-Api-Key": self.dify_api_inner_api_key},
|
||||
headers={"X-Inner-Api-Key": self.inner_api_key},
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AgentStubFileRequestError(504, "Dify API file request timed out") from exc
|
||||
|
||||
@ -28,7 +28,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||
Side effects:
|
||||
Starts either ``dify_agent.agent_stub.server.app:app`` via
|
||||
``uvicorn.run`` or the grpclib Agent Stub server depending on the
|
||||
configured ``DIFY_AGENT_STUB_URL`` scheme.
|
||||
configured ``DIFY_AGENT_STUB_API_BASE_URL`` scheme.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(prog="dify-agent-stub-server")
|
||||
parser.add_argument("--host", default=None)
|
||||
@ -36,7 +36,10 @@ def main(argv: list[str] | None = None) -> None:
|
||||
parser.add_argument("--reload", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
settings = ServerSettings()
|
||||
if settings.agent_stub_url is not None and parse_agent_stub_endpoint(settings.agent_stub_url).is_grpc:
|
||||
if (
|
||||
settings.agent_stub_api_base_url is not None
|
||||
and parse_agent_stub_endpoint(settings.agent_stub_api_base_url).is_grpc
|
||||
):
|
||||
asyncio.run(_serve_grpc(settings=settings, host=args.host, port=args.port))
|
||||
return
|
||||
uvicorn.run(
|
||||
@ -49,14 +52,14 @@ def main(argv: list[str] | None = None) -> None:
|
||||
|
||||
async def _serve_grpc(*, settings: ServerSettings, host: str | None, port: int | None) -> None:
|
||||
bind_target = derive_agent_stub_grpc_bind_target(
|
||||
public_url=settings.agent_stub_url or "",
|
||||
public_url=settings.agent_stub_api_base_url or "",
|
||||
bind_address=settings.agent_stub_grpc_bind_address,
|
||||
)
|
||||
if host is not None or port is not None:
|
||||
bind_target = AgentStubGRPCBindTarget(host=host or bind_target.host, port=port or bind_target.port)
|
||||
|
||||
server = await start_agent_stub_grpc_server(
|
||||
public_url=settings.agent_stub_url or "",
|
||||
public_url=settings.agent_stub_api_base_url or "",
|
||||
bind_address=bind_target.address,
|
||||
token_codec=settings.create_agent_stub_token_codec(),
|
||||
file_request_handler=settings.create_agent_stub_file_request_handler(),
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
"""Server-side environment injection helpers for Agent Stub forwarding.
|
||||
|
||||
Only user-visible ``shell.run`` commands receive these variables. Internal
|
||||
lifecycle commands remain free of Agent Stub credentials so workspace setup and
|
||||
cleanup cannot accidentally inherit user-facing forwarding state.
|
||||
lifecycle commands remain free of Agent Stub credentials and drive-base defaults
|
||||
so workspace setup and cleanup cannot accidentally inherit user-facing forwarding
|
||||
state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -11,8 +12,10 @@ from typing import Protocol
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_URL_ENV_VAR,
|
||||
normalize_agent_stub_url,
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR,
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR,
|
||||
agent_stub_drive_base_for_ref,
|
||||
normalize_agent_stub_api_base_url,
|
||||
)
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
@ -25,23 +28,31 @@ class ShellAgentStubTokenFactory(Protocol):
|
||||
|
||||
def build_shell_agent_stub_env(
|
||||
*,
|
||||
agent_stub_url: str | None,
|
||||
agent_stub_api_base_url: str | None,
|
||||
agent_stub_drive_ref: str | None = None,
|
||||
execution_context: DifyExecutionContextLayerConfig | None,
|
||||
token_factory: ShellAgentStubTokenFactory | None,
|
||||
session_id: str | None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Build the shell-visible Agent Stub environment for one user command."""
|
||||
if agent_stub_url is None or execution_context is None or token_factory is None:
|
||||
"""Build the shell-visible Agent Stub environment for one user command.
|
||||
|
||||
``agent_stub_drive_ref`` is the storage reference from the bound
|
||||
``dify.drive`` layer. The sandbox-local base is fixed by the Agent Stub
|
||||
contract and derived here at shell-run injection time.
|
||||
"""
|
||||
if agent_stub_api_base_url is None or execution_context is None or token_factory is None:
|
||||
return None
|
||||
return {
|
||||
AGENT_STUB_URL_ENV_VAR: normalize_agent_stub_url(agent_stub_url),
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR: normalize_agent_stub_api_base_url(agent_stub_api_base_url),
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR: token_factory(execution_context, session_id=session_id),
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR: agent_stub_drive_base_for_ref(agent_stub_drive_ref),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AGENT_STUB_AUTH_JWE_ENV_VAR",
|
||||
"AGENT_STUB_URL_ENV_VAR",
|
||||
"AGENT_STUB_DRIVE_BASE_ENV_VAR",
|
||||
"AGENT_STUB_API_BASE_URL_ENV_VAR",
|
||||
"ShellAgentStubTokenFactory",
|
||||
"build_shell_agent_stub_env",
|
||||
]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Client-safe exports for the Dify drive declaration layer DTOs.
|
||||
"""Client-safe exports for the Dify drive runtime catalog DTOs.
|
||||
|
||||
The layer implementation lives in the sibling ``layer`` module. Keep this
|
||||
package root import-safe for client code that only builds run requests.
|
||||
@ -6,14 +6,12 @@ package root import-safe for client code that only builds run requests.
|
||||
|
||||
from dify_agent.layers.drive.configs import (
|
||||
DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
DifyDriveFileConfig,
|
||||
DifyDriveLayerConfig,
|
||||
DifyDriveSkillConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DIFY_DRIVE_LAYER_TYPE_ID",
|
||||
"DifyDriveFileConfig",
|
||||
"DifyDriveLayerConfig",
|
||||
"DifyDriveSkillConfig",
|
||||
]
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
"""Client-safe DTOs for the Dify drive declaration layer.
|
||||
|
||||
The drive layer is a config-only manifest of the Skills & Files an agent has
|
||||
in its drive. It is an index, never the content: each entry carries only a
|
||||
display name, a model-facing description, and the drive key needed to fetch
|
||||
the real bytes through the back proxy (``GET /inner/api/drive/<drive_ref>/
|
||||
manifest`` → internal download URL). Inlining SKILL.md bodies here would break
|
||||
the PRD's dynamic-loading principle and bloat every run request.
|
||||
The drive layer carries the runtime drive catalog plus the prompt-mentioned
|
||||
targets that must be pulled eagerly when the layer enters. It is still config
|
||||
only: skills are declared as metadata, not content, and plain files are listed
|
||||
only when the prompt explicitly mentions their drive keys.
|
||||
|
||||
The API backend catalogs and writes this config; the Agent backend consumes it
|
||||
(ENG-387: pull via back proxy, lazy-load SKILL.md, materialize files).
|
||||
@ -22,7 +20,7 @@ DIFY_DRIVE_LAYER_TYPE_ID: Final[str] = "dify.drive"
|
||||
|
||||
|
||||
class DifyDriveSkillConfig(BaseModel):
|
||||
"""Runtime declaration of one standardized skill — an index, not content."""
|
||||
"""Runtime declaration of one standardized skill — metadata, not content."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@ -33,35 +31,23 @@ class DifyDriveSkillConfig(BaseModel):
|
||||
skill_md_key: str
|
||||
# "<slug>/.DIFY-SKILL-FULL.zip" — full archive for restoring the complete skill.
|
||||
archive_key: str | None = None
|
||||
|
||||
|
||||
class DifyDriveFileConfig(BaseModel):
|
||||
"""Runtime declaration of one plain drive file."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
# "files/<filename>" — the drive key of the file value.
|
||||
key: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
path: str
|
||||
|
||||
|
||||
class DifyDriveLayerConfig(LayerConfig):
|
||||
"""Config-only declaration layer: API writes the catalog, the agent pulls
|
||||
the listed entries through the back proxy using ``drive_ref``."""
|
||||
"""Drive runtime catalog plus eager-pull instructions for mentioned targets."""
|
||||
|
||||
# "agent-<agent_id>" — storage addressing, deliberately explicit instead of
|
||||
# derived from execution context so a shared (non-agent-bound) drive stays
|
||||
# possible later.
|
||||
drive_ref: str
|
||||
skills: list[DifyDriveSkillConfig] = Field(default_factory=list)
|
||||
files: list[DifyDriveFileConfig] = Field(default_factory=list)
|
||||
mentioned_skill_keys: list[str] = Field(default_factory=list)
|
||||
mentioned_file_keys: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DIFY_DRIVE_LAYER_TYPE_ID",
|
||||
"DifyDriveFileConfig",
|
||||
"DifyDriveLayerConfig",
|
||||
"DifyDriveSkillConfig",
|
||||
]
|
||||
|
||||
@ -1,34 +1,328 @@
|
||||
"""Inert Dify drive declaration layer.
|
||||
"""Runtime Dify drive layer with eager pull for prompt-mentioned targets.
|
||||
|
||||
Registering this layer makes ``dify.drive`` a known composition type id so a
|
||||
run that carries the declaration never fails as "unknown layer type", even
|
||||
before the consumption work (ENG-387) lands. It deliberately contributes no
|
||||
prompt and no tools: a model that can see skill names but cannot read SKILL.md
|
||||
would only hallucinate. The skills prompt (including the "pull SKILL.md via
|
||||
drive" guidance) ships together with the consumption implementation.
|
||||
The API backend sends the full drive skill catalog plus the ordered drive keys
|
||||
mentioned in the prompt. When the layer enters a run context it eagerly pulls
|
||||
those mentioned skills/files from the Dify inner drive bridge, materializes them
|
||||
under the fixed Agent Stub drive base for ``drive_ref``, and contributes a
|
||||
concise prompt block describing what was loaded and what other skills remain
|
||||
available for lazy pull.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path, PurePosixPath
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, ClassVar, cast
|
||||
from uuid import uuid4
|
||||
from zipfile import BadZipFile, ZipFile, ZipInfo
|
||||
|
||||
import httpx
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer
|
||||
from agenton.layers import EmptyRuntimeState, Layer, LayerDeps, PlainLayer
|
||||
from dify_agent.agent_stub.protocol import agent_stub_drive_base_for_ref
|
||||
from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
|
||||
|
||||
_SKILL_ARCHIVE_FILENAME = ".DIFY-SKILL-FULL.zip"
|
||||
_DOWNLOAD_CONCURRENCY = 4
|
||||
|
||||
|
||||
class DifyDriveLayerError(RuntimeError):
|
||||
"""Raised when one eager-pull drive operation fails."""
|
||||
|
||||
|
||||
class DifyDriveDeps(LayerDeps):
|
||||
execution_context: Layer[Any, Any, Any, Any, Any, Any] # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DriveManifestItem:
|
||||
key: str
|
||||
download_url: str
|
||||
size: int | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyDriveLayer(PlainLayer[NoLayerDeps, DifyDriveLayerConfig, EmptyRuntimeState]):
|
||||
"""Config-only carrier of the drive Skills & Files manifest."""
|
||||
class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntimeState]):
|
||||
"""Drive runtime layer that eagerly materializes prompt-mentioned drive targets."""
|
||||
|
||||
type_id: ClassVar[str | None] = DIFY_DRIVE_LAYER_TYPE_ID
|
||||
|
||||
config: DifyDriveLayerConfig
|
||||
inner_api_url: str
|
||||
inner_api_key: str
|
||||
_loaded_skill_bodies: dict[str, str] = field(default_factory=dict)
|
||||
_pulled_file_paths: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyDriveLayerConfig) -> Self:
|
||||
return cls(config=config)
|
||||
del config
|
||||
raise TypeError("DifyDriveLayer requires server-side Dify API settings and must use a provider factory.")
|
||||
|
||||
@classmethod
|
||||
def from_config_with_settings(
|
||||
cls,
|
||||
config: DifyDriveLayerConfig,
|
||||
*,
|
||||
inner_api_url: str,
|
||||
inner_api_key: str,
|
||||
) -> Self:
|
||||
return cls(
|
||||
config=DifyDriveLayerConfig.model_validate(config),
|
||||
inner_api_url=inner_api_url.rstrip("/"),
|
||||
inner_api_key=inner_api_key,
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def prefix_prompts(self) -> list[str]:
|
||||
return [self.build_prompt_context()]
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
await self._pull_mentioned_targets()
|
||||
|
||||
@override
|
||||
async def on_context_resume(self) -> None:
|
||||
await self._pull_mentioned_targets()
|
||||
|
||||
def build_prompt_context(self) -> str:
|
||||
sections: list[str] = []
|
||||
|
||||
loaded_skill_sections: list[str] = []
|
||||
for skill_key in self.config.mentioned_skill_keys:
|
||||
body = self._loaded_skill_bodies.get(skill_key)
|
||||
if body is None:
|
||||
continue
|
||||
skill = next((item for item in self.config.skills if item.skill_md_key == skill_key), None)
|
||||
if skill is None:
|
||||
continue
|
||||
loaded_skill_sections.append(f"Path: {skill.path}\nName: {skill.name}\nSKILL.md:\n{body}")
|
||||
if loaded_skill_sections:
|
||||
sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections))
|
||||
|
||||
mentioned_files = [
|
||||
f"- {key} -> {self._pulled_file_paths[key]}"
|
||||
for key in self.config.mentioned_file_keys
|
||||
if key in self._pulled_file_paths
|
||||
]
|
||||
if mentioned_files:
|
||||
sections.append("Mentioned files pulled to local drive:\n" + "\n".join(mentioned_files))
|
||||
|
||||
other_skills = [
|
||||
f"- {skill.path}: {skill.name} — {skill.description}"
|
||||
for skill in self.config.skills
|
||||
if skill.skill_md_key not in set(self.config.mentioned_skill_keys)
|
||||
]
|
||||
if other_skills:
|
||||
sections.append("Other available skills:\n" + "\n".join(other_skills))
|
||||
|
||||
if not sections:
|
||||
return ""
|
||||
sections.append(
|
||||
"Additional drive skills/files can be pulled lazily later with the Agent Stub drive commands if needed."
|
||||
)
|
||||
return "\n\n".join(sections)
|
||||
|
||||
async def _pull_mentioned_targets(self) -> None:
|
||||
self._loaded_skill_bodies = {}
|
||||
self._pulled_file_paths = {}
|
||||
targets: list[tuple[str, bool]] = [
|
||||
(self._skill_prefix(skill_key), False) for skill_key in self.config.mentioned_skill_keys
|
||||
] + [(file_key, True) for file_key in self.config.mentioned_file_keys]
|
||||
if not targets:
|
||||
return
|
||||
|
||||
tenant_id = self._require_tenant_id()
|
||||
manifest_items = await self._fetch_manifest_items(tenant_id=tenant_id, targets=targets)
|
||||
written_paths = await self._download_items(manifest_items)
|
||||
self._pulled_file_paths = written_paths
|
||||
for file_key in self.config.mentioned_file_keys:
|
||||
if file_key not in written_paths:
|
||||
raise DifyDriveLayerError(f"missing pulled file for mentioned drive key {file_key}")
|
||||
for skill_key in self.config.mentioned_skill_keys:
|
||||
skill_path = written_paths.get(skill_key)
|
||||
if skill_path is None:
|
||||
raise DifyDriveLayerError(f"missing pulled SKILL.md for mentioned skill {skill_key}")
|
||||
try:
|
||||
self._loaded_skill_bodies[skill_key] = Path(skill_path).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise DifyDriveLayerError(f"failed to load pulled SKILL.md for mentioned skill {skill_key}") from exc
|
||||
|
||||
async def _fetch_manifest_items(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
targets: list[tuple[str, bool]],
|
||||
) -> list[_DriveManifestItem]:
|
||||
semaphore = asyncio.Semaphore(_DOWNLOAD_CONCURRENCY)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True, trust_env=False) as client:
|
||||
|
||||
async def fetch_one(target: tuple[str, bool]) -> list[_DriveManifestItem]:
|
||||
prefix, exact = target
|
||||
try:
|
||||
async with semaphore:
|
||||
response = await client.get(
|
||||
f"{self.inner_api_url}/inner/api/drive/{self.config.drive_ref}/manifest",
|
||||
params={
|
||||
"tenant_id": tenant_id,
|
||||
"prefix": prefix,
|
||||
"include_download_url": "true",
|
||||
},
|
||||
headers={"X-Inner-Api-Key": self.inner_api_key},
|
||||
)
|
||||
except (httpx.InvalidURL, httpx.TimeoutException, httpx.RequestError) as exc:
|
||||
raise DifyDriveLayerError(f"drive manifest request failed for {prefix}") from exc
|
||||
if response.is_error:
|
||||
raise DifyDriveLayerError(f"drive manifest request failed for {prefix}: {response.status_code}")
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise DifyDriveLayerError(f"drive manifest response is invalid for {prefix}") from exc
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list):
|
||||
raise DifyDriveLayerError(f"drive manifest response is invalid for {prefix}")
|
||||
manifest_items: list[_DriveManifestItem] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = item.get("key")
|
||||
download_url = item.get("download_url")
|
||||
if not isinstance(key, str) or not isinstance(download_url, str) or not download_url:
|
||||
raise DifyDriveLayerError(f"drive manifest item is missing download_url for {prefix}")
|
||||
if exact and key != prefix:
|
||||
continue
|
||||
manifest_items.append(_DriveManifestItem(key=key, download_url=download_url, size=item.get("size")))
|
||||
return manifest_items
|
||||
|
||||
grouped_items = await asyncio.gather(*(fetch_one(target) for target in targets))
|
||||
|
||||
deduplicated: dict[str, _DriveManifestItem] = {}
|
||||
for items in grouped_items:
|
||||
for item in items:
|
||||
deduplicated.setdefault(item.key, item)
|
||||
return [deduplicated[key] for key in sorted(deduplicated)]
|
||||
|
||||
async def _download_items(self, items: list[_DriveManifestItem]) -> dict[str, str]:
|
||||
base_path = Path(agent_stub_drive_base_for_ref(self.config.drive_ref))
|
||||
try:
|
||||
base_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise DifyDriveLayerError(f"failed to prepare drive base {base_path}") from exc
|
||||
semaphore = asyncio.Semaphore(_DOWNLOAD_CONCURRENCY)
|
||||
archive_paths: list[Path] = []
|
||||
canonical_skill_dirs = {item.key.rsplit("/", 1)[0] for item in items if item.key.endswith("/SKILL.md")}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True, trust_env=False) as client:
|
||||
|
||||
async def download_one(item: _DriveManifestItem) -> tuple[str, str]:
|
||||
try:
|
||||
async with semaphore:
|
||||
response = await client.get(item.download_url)
|
||||
except (httpx.InvalidURL, httpx.TimeoutException, httpx.RequestError) as exc:
|
||||
raise DifyDriveLayerError(f"drive download failed for {item.key}") from exc
|
||||
if response.is_error:
|
||||
raise DifyDriveLayerError(f"drive download failed for {item.key}: {response.status_code}")
|
||||
payload = response.content
|
||||
if item.size is not None and len(payload) != item.size:
|
||||
raise DifyDriveLayerError(f"downloaded drive file size mismatch for {item.key}")
|
||||
try:
|
||||
destination = _resolve_drive_destination(base_path, item.key)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}")
|
||||
temp_path.write_bytes(payload)
|
||||
temp_path.replace(destination)
|
||||
except OSError as exc:
|
||||
raise DifyDriveLayerError(f"failed to materialize drive file {item.key}") from exc
|
||||
if destination.name == _SKILL_ARCHIVE_FILENAME:
|
||||
archive_paths.append(destination)
|
||||
return item.key, str(destination)
|
||||
|
||||
pairs = await asyncio.gather(*(download_one(item) for item in items))
|
||||
for archive_path in sorted(archive_paths):
|
||||
archive_skill_dir = archive_path.parent.relative_to(base_path).as_posix()
|
||||
skip_entry_names = {"SKILL.md"} if archive_skill_dir in canonical_skill_dirs else set()
|
||||
_extract_skill_archive(archive_path, skip_entry_names=skip_entry_names)
|
||||
return {key: path for key, path in pairs}
|
||||
|
||||
def _require_tenant_id(self) -> str:
|
||||
execution_context = self.deps.execution_context.config
|
||||
tenant_id = getattr(execution_context, "tenant_id", None)
|
||||
if not isinstance(tenant_id, str) or not tenant_id.strip():
|
||||
raise DifyDriveLayerError("DifyDriveLayer requires execution_context.tenant_id")
|
||||
return cast(str, tenant_id).strip()
|
||||
|
||||
@staticmethod
|
||||
def _skill_prefix(skill_key: str) -> str:
|
||||
return f"{skill_key.rsplit('/', 1)[0]}/"
|
||||
|
||||
|
||||
__all__ = ["DifyDriveLayer"]
|
||||
def _resolve_drive_destination(base_path: Path, drive_key: str) -> Path:
|
||||
destination = (base_path / Path(drive_key)).resolve()
|
||||
try:
|
||||
destination.relative_to(base_path)
|
||||
except ValueError as exc:
|
||||
raise DifyDriveLayerError(f"drive key resolves outside the drive base: {drive_key}") from exc
|
||||
return destination
|
||||
|
||||
|
||||
def _extract_skill_archive(archive_path: Path, *, skip_entry_names: set[str]) -> None:
|
||||
target_dir = archive_path.parent.resolve()
|
||||
try:
|
||||
with TemporaryDirectory(dir=target_dir, prefix=".dify-skill-extract-") as staging_dir_name:
|
||||
staging_dir = Path(staging_dir_name).resolve()
|
||||
with ZipFile(archive_path) as archive:
|
||||
for zip_info in archive.infolist():
|
||||
if zip_info.filename.replace("\\", "/").rstrip("/") in skip_entry_names:
|
||||
continue
|
||||
destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename)
|
||||
if _is_zip_symlink(zip_info):
|
||||
raise DifyDriveLayerError(
|
||||
f"skill archive contains unsupported symlink entry: {zip_info.filename}"
|
||||
)
|
||||
if zip_info.is_dir():
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(zip_info) as source_file:
|
||||
temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}")
|
||||
temp_path.write_bytes(source_file.read())
|
||||
temp_path.replace(destination)
|
||||
for staged_path in sorted(staging_dir.rglob("*")):
|
||||
if staged_path.is_dir():
|
||||
continue
|
||||
relative_path = staged_path.relative_to(staging_dir)
|
||||
destination = (target_dir / relative_path).resolve()
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
staged_path.replace(destination)
|
||||
except DifyDriveLayerError:
|
||||
raise
|
||||
except (BadZipFile, OSError) as exc:
|
||||
raise DifyDriveLayerError(f"downloaded skill archive is invalid: {archive_path.name}") from exc
|
||||
|
||||
|
||||
def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path:
|
||||
normalized_name = entry_name.replace("\\", "/")
|
||||
pure_path = PurePosixPath(normalized_name)
|
||||
if not normalized_name or normalized_name.startswith("/") or pure_path.is_absolute():
|
||||
raise DifyDriveLayerError(f"skill archive contains unsafe absolute path: {entry_name}")
|
||||
if any(part in {"", ".", ".."} for part in pure_path.parts):
|
||||
raise DifyDriveLayerError(f"skill archive contains unsafe path traversal entry: {entry_name}")
|
||||
destination = (target_dir / Path(*pure_path.parts)).resolve()
|
||||
try:
|
||||
destination.relative_to(target_dir)
|
||||
except ValueError as exc:
|
||||
raise DifyDriveLayerError(f"skill archive entry resolves outside the skill directory: {entry_name}") from exc
|
||||
return destination
|
||||
|
||||
|
||||
def _is_zip_symlink(zip_info: ZipInfo) -> bool:
|
||||
file_mode = zip_info.external_attr >> 16
|
||||
return (file_mode & 0o170000) == 0o120000
|
||||
|
||||
|
||||
__all__ = ["DifyDriveLayer", "DifyDriveLayerError"]
|
||||
|
||||
@ -67,8 +67,8 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
|
||||
type_id: ClassVar[str | None] = DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
|
||||
|
||||
config: DifyKnowledgeBaseLayerConfig
|
||||
dify_api_inner_url: str
|
||||
dify_api_inner_api_key: str
|
||||
inner_api_url: str
|
||||
inner_api_key: str
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
@ -84,14 +84,14 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
|
||||
cls,
|
||||
config: DifyKnowledgeBaseLayerConfig,
|
||||
*,
|
||||
dify_api_inner_url: str,
|
||||
dify_api_inner_api_key: str,
|
||||
inner_api_url: str,
|
||||
inner_api_key: str,
|
||||
) -> Self:
|
||||
"""Create the layer from public config plus server-only API settings."""
|
||||
return cls(
|
||||
config=DifyKnowledgeBaseLayerConfig.model_validate(config),
|
||||
dify_api_inner_url=dify_api_inner_url,
|
||||
dify_api_inner_api_key=dify_api_inner_api_key,
|
||||
inner_api_url=inner_api_url,
|
||||
inner_api_key=inner_api_key,
|
||||
)
|
||||
|
||||
async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
@ -114,8 +114,8 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
|
||||
execution_context = self.deps.execution_context.config
|
||||
caller = _build_caller_context(execution_context)
|
||||
client = DifyKnowledgeBaseClient(
|
||||
base_url=self.dify_api_inner_url,
|
||||
api_key=self.dify_api_inner_api_key,
|
||||
base_url=self.inner_api_url,
|
||||
api_key=self.inner_api_key,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
|
||||
@ -49,6 +49,7 @@ from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import LayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
|
||||
from dify_agent.layers.drive.layer import DifyDriveLayer
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
|
||||
@ -168,8 +169,13 @@ type ShellInterruptToolResult = ShellJobStatusObservation | ShellToolErrorObserv
|
||||
|
||||
|
||||
class DifyShellLayerDeps(LayerDeps):
|
||||
"""Optional direct-layer dependencies used by the shell runtime layer."""
|
||||
"""Optional direct-layer dependencies used by the shell runtime layer.
|
||||
|
||||
The drive dependency supplies the drive ref for injected
|
||||
Agent Stub CLI commands; the execution context supplies the token principal.
|
||||
"""
|
||||
|
||||
drive: DifyDriveLayer | None # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
execution_context: DifyExecutionContextLayer | None # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@ -307,7 +313,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
config: DifyShellLayerConfig
|
||||
shellctl_entrypoint: str
|
||||
shellctl_client_factory: ShellctlClientFactory
|
||||
agent_stub_url: str | None = None
|
||||
agent_stub_api_base_url: str | None = None
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
|
||||
_shellctl_client: ShellctlClientProtocol | None = None
|
||||
|
||||
@ -325,7 +331,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
*,
|
||||
shellctl_entrypoint: str | None,
|
||||
shellctl_client_factory: ShellctlClientFactory,
|
||||
agent_stub_url: str | None = None,
|
||||
agent_stub_api_base_url: str | None = None,
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None,
|
||||
) -> Self:
|
||||
"""Create the layer from public config plus server-only shell settings."""
|
||||
@ -338,7 +344,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
config=config,
|
||||
shellctl_entrypoint=normalized_entrypoint,
|
||||
shellctl_client_factory=shellctl_client_factory,
|
||||
agent_stub_url=agent_stub_url,
|
||||
agent_stub_api_base_url=agent_stub_api_base_url,
|
||||
agent_stub_token_factory=agent_stub_token_factory,
|
||||
)
|
||||
layer.bind_deps({})
|
||||
@ -760,8 +766,10 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
"""Build per-command Agent Stub env only for user-visible ``shell.run``."""
|
||||
execution_context_layer = self.deps.execution_context
|
||||
execution_context = execution_context_layer.config if execution_context_layer is not None else None
|
||||
drive_layer = self.deps.drive
|
||||
return build_shell_agent_stub_env(
|
||||
agent_stub_url=self.agent_stub_url,
|
||||
agent_stub_api_base_url=self.agent_stub_api_base_url,
|
||||
agent_stub_drive_ref=drive_layer.config.drive_ref if drive_layer is not None else None,
|
||||
execution_context=execution_context,
|
||||
token_factory=self.agent_stub_token_factory,
|
||||
session_id=self.runtime_state.session_id,
|
||||
|
||||
@ -6,7 +6,7 @@ state-free Dify structured output layer, the optional Dify ask-human layer, the
|
||||
Dify execution-context layer, the stateful Dify shell layer, and the Dify
|
||||
plugin/knowledge business-layer family:
|
||||
|
||||
- ``dify.drive`` for the inert Skills & Files drive declaration,
|
||||
- ``dify.drive`` for drive-backed skill catalog + eager pull,
|
||||
- ``dify.execution_context`` for shared tenant/user/run daemon context,
|
||||
- ``dify.shell`` for shellctl-backed shell job control,
|
||||
- ``dify.plugin.llm`` for plugin-backed model selection,
|
||||
@ -38,6 +38,7 @@ from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
from dify_agent.layers.ask_human.layer import DifyAskHumanLayer
|
||||
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
|
||||
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
|
||||
from dify_agent.layers.drive import DifyDriveLayerConfig
|
||||
from dify_agent.layers.drive.layer import DifyDriveLayer
|
||||
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
@ -54,11 +55,11 @@ def create_default_layer_providers(
|
||||
*,
|
||||
plugin_daemon_url: str = "http://localhost:5002",
|
||||
plugin_daemon_api_key: str = "",
|
||||
dify_api_inner_url: str = "http://localhost:5001",
|
||||
dify_api_inner_api_key: str = "",
|
||||
inner_api_url: str = "http://localhost:5001",
|
||||
inner_api_key: str = "",
|
||||
shellctl_entrypoint: str | None = None,
|
||||
shellctl_auth_token: str | None = None,
|
||||
agent_stub_url: str | None = None,
|
||||
agent_stub_api_base_url: str | None = None,
|
||||
agent_stub_token_codec: AgentStubTokenCodec | None = None,
|
||||
) -> tuple[DifyAgentLayerProvider, ...]:
|
||||
"""Return the server provider set of safe config-constructible layers.
|
||||
@ -89,10 +90,14 @@ def create_default_layer_providers(
|
||||
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
|
||||
LayerProvider.from_layer_type(DifyOutputLayer),
|
||||
LayerProvider.from_layer_type(DifyAskHumanLayer),
|
||||
# Inert declaration layer: makes ``dify.drive`` a known type id so runs
|
||||
# carrying the Skills & Files manifest never fail before the consumption
|
||||
# work (ENG-387) lands. Deliberately contributes no prompt and no tools.
|
||||
LayerProvider.from_layer_type(DifyDriveLayer),
|
||||
LayerProvider.from_factory(
|
||||
layer_type=DifyDriveLayer,
|
||||
create=lambda config: DifyDriveLayer.from_config_with_settings(
|
||||
DifyDriveLayerConfig.model_validate(config),
|
||||
inner_api_url=inner_api_url,
|
||||
inner_api_key=inner_api_key,
|
||||
),
|
||||
),
|
||||
LayerProvider.from_factory(
|
||||
layer_type=DifyExecutionContextLayer,
|
||||
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(
|
||||
@ -107,7 +112,7 @@ def create_default_layer_providers(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shellctl_entrypoint=shellctl_entrypoint,
|
||||
shellctl_client_factory=create_shellctl_client_factory(token=shellctl_token),
|
||||
agent_stub_url=agent_stub_url,
|
||||
agent_stub_api_base_url=agent_stub_api_base_url,
|
||||
agent_stub_token_factory=agent_stub_token_factory,
|
||||
),
|
||||
),
|
||||
@ -117,8 +122,8 @@ def create_default_layer_providers(
|
||||
layer_type=DifyKnowledgeBaseLayer,
|
||||
create=lambda config: DifyKnowledgeBaseLayer.from_config_with_settings(
|
||||
DifyKnowledgeBaseLayerConfig.model_validate(config),
|
||||
dify_api_inner_url=dify_api_inner_url,
|
||||
dify_api_inner_api_key=dify_api_inner_api_key,
|
||||
inner_api_url=inner_api_url,
|
||||
inner_api_key=inner_api_key,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@ -10,7 +10,7 @@ stay state-only: they borrow the lifespan-owned clients through the runner and
|
||||
receive shell-layer server settings through provider construction rather than
|
||||
reading environment variables themselves. The standard server always mounts the
|
||||
HTTP Agent Stub router and additionally starts the optional grpclib Agent Stub
|
||||
server when ``DIFY_AGENT_STUB_URL`` uses ``grpc://``.
|
||||
server when ``DIFY_AGENT_STUB_API_BASE_URL`` uses ``grpc://``.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
@ -41,11 +41,11 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
layer_providers = create_default_layer_providers(
|
||||
plugin_daemon_url=resolved_settings.plugin_daemon_url,
|
||||
plugin_daemon_api_key=resolved_settings.plugin_daemon_api_key,
|
||||
dify_api_inner_url=resolved_settings.dify_api_inner_url,
|
||||
dify_api_inner_api_key=resolved_settings.dify_api_inner_api_key or "",
|
||||
inner_api_url=resolved_settings.inner_api_url,
|
||||
inner_api_key=resolved_settings.inner_api_key or "",
|
||||
shellctl_entrypoint=resolved_settings.shellctl_entrypoint,
|
||||
shellctl_auth_token=resolved_settings.shellctl_auth_token,
|
||||
agent_stub_url=resolved_settings.agent_stub_url,
|
||||
agent_stub_api_base_url=resolved_settings.agent_stub_api_base_url,
|
||||
agent_stub_token_codec=agent_stub_token_codec,
|
||||
)
|
||||
sandbox_file_service = (
|
||||
@ -72,11 +72,11 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
)
|
||||
grpc_server = None
|
||||
if (
|
||||
resolved_settings.agent_stub_url is not None
|
||||
and parse_agent_stub_endpoint(resolved_settings.agent_stub_url).is_grpc
|
||||
resolved_settings.agent_stub_api_base_url is not None
|
||||
and parse_agent_stub_endpoint(resolved_settings.agent_stub_api_base_url).is_grpc
|
||||
):
|
||||
grpc_server = await start_agent_stub_grpc_server(
|
||||
public_url=resolved_settings.agent_stub_url,
|
||||
public_url=resolved_settings.agent_stub_api_base_url,
|
||||
bind_address=resolved_settings.agent_stub_grpc_bind_address,
|
||||
token_codec=agent_stub_token_codec,
|
||||
file_request_handler=agent_stub_file_request_handler,
|
||||
|
||||
@ -5,9 +5,9 @@ Outbound HTTP client settings describe the FastAPI lifespan-owned
|
||||
Dify API inner calls. Layers and Agenton providers do not own those clients, so
|
||||
these settings are process resource limits rather than per-run lifecycle knobs.
|
||||
Endpoint URLs and API keys stay service-specific. The Agent Stub also uses this
|
||||
settings model directly: the public Agent Stub URL, server secret, optional gRPC
|
||||
bind override, and optional Dify inner API file/drive request settings all live
|
||||
here under the longstanding ``DIFY_AGENT_...`` environment-variable namespace.
|
||||
settings model directly: the public Agent Stub API base URL, server secret,
|
||||
optional gRPC bind override, and optional Dify inner API bridge settings all
|
||||
live here under the ``DIFY_AGENT_...`` environment-variable namespace.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
@ -17,7 +17,7 @@ from typing import ClassVar
|
||||
from pydantic import AnyHttpUrl, Field, TypeAdapter, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_url, parse_agent_stub_endpoint
|
||||
from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_api_base_url, parse_agent_stub_endpoint
|
||||
from dify_agent.agent_stub.server.agent_stub_drive import DifyApiAgentStubDriveRequestHandler
|
||||
from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.grpc_bind import normalize_agent_stub_grpc_bind_address
|
||||
@ -35,12 +35,11 @@ class ServerSettings(BaseSettings):
|
||||
run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1)
|
||||
plugin_daemon_url: str = "http://localhost:5002"
|
||||
plugin_daemon_api_key: str = ""
|
||||
dify_api_inner_url: str = "http://localhost:5001"
|
||||
dify_api_base_url: str | None = None
|
||||
dify_api_inner_api_key: str | None = None
|
||||
inner_api_url: str = "http://localhost:5001"
|
||||
inner_api_key: str | None = None
|
||||
shellctl_entrypoint: str | None = None
|
||||
shellctl_auth_token: str | None = None
|
||||
agent_stub_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_URL")
|
||||
agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL")
|
||||
agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS")
|
||||
server_secret_key: str | None = None
|
||||
outbound_http_connect_timeout: float = Field(default=10.0, ge=0)
|
||||
@ -58,9 +57,9 @@ class ServerSettings(BaseSettings):
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@field_validator("agent_stub_url")
|
||||
@field_validator("agent_stub_api_base_url")
|
||||
@classmethod
|
||||
def normalize_agent_stub_url_value(cls, value: str | None) -> str | None:
|
||||
def normalize_agent_stub_api_base_url_value(cls, value: str | None) -> str | None:
|
||||
"""Normalize the public Agent Stub URL while still validating its scheme."""
|
||||
if value is None:
|
||||
return None
|
||||
@ -69,8 +68,8 @@ class ServerSettings(BaseSettings):
|
||||
return None
|
||||
if stripped.startswith(("http://", "https://")):
|
||||
validated = str(TypeAdapter(AnyHttpUrl).validate_python(stripped))
|
||||
return normalize_agent_stub_url(validated)
|
||||
return normalize_agent_stub_url(stripped)
|
||||
return normalize_agent_stub_api_base_url(validated)
|
||||
return normalize_agent_stub_api_base_url(stripped)
|
||||
|
||||
@field_validator("agent_stub_grpc_bind_address")
|
||||
@classmethod
|
||||
@ -95,24 +94,22 @@ class ServerSettings(BaseSettings):
|
||||
_ = decode_server_secret_key(stripped)
|
||||
return stripped
|
||||
|
||||
@field_validator("dify_api_base_url")
|
||||
@field_validator("inner_api_url")
|
||||
@classmethod
|
||||
def normalize_dify_api_base_url(cls, value: str | None) -> str | None:
|
||||
"""Normalize the trusted Dify API base URL used for file request calls."""
|
||||
if value is None:
|
||||
return None
|
||||
def normalize_inner_api_url(cls, value: str) -> str:
|
||||
"""Normalize the trusted Dify API base URL used for inner API calls."""
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
raise ValueError("DIFY_AGENT_INNER_API_URL must not be empty")
|
||||
validated = str(TypeAdapter(AnyHttpUrl).validate_python(stripped))
|
||||
parsed = validated.rstrip("/")
|
||||
if "?" in parsed or "#" in parsed:
|
||||
raise ValueError("DIFY_AGENT_DIFY_API_BASE_URL must not include a query string or fragment")
|
||||
raise ValueError("DIFY_AGENT_INNER_API_URL must not include a query string or fragment")
|
||||
return parsed
|
||||
|
||||
@field_validator("dify_api_inner_api_key")
|
||||
@field_validator("inner_api_key")
|
||||
@classmethod
|
||||
def normalize_dify_api_inner_api_key(cls, value: str | None) -> str | None:
|
||||
def normalize_inner_api_key(cls, value: str | None) -> str | None:
|
||||
"""Normalize the optional trusted Dify inner API key."""
|
||||
if value is None:
|
||||
return None
|
||||
@ -121,16 +118,16 @@ class ServerSettings(BaseSettings):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_agent_stub_requirements(self) -> "ServerSettings":
|
||||
"""Require Agent Stub settings while allowing knowledge-only inner API keys."""
|
||||
if self.agent_stub_url is not None and self.server_secret_key is None:
|
||||
raise ValueError("DIFY_AGENT_SERVER_SECRET_KEY is required when DIFY_AGENT_STUB_URL is set.")
|
||||
"""Require Agent Stub settings while allowing deployments without inner API calls."""
|
||||
if self.agent_stub_api_base_url is not None and self.server_secret_key is None:
|
||||
raise ValueError("DIFY_AGENT_SERVER_SECRET_KEY is required when DIFY_AGENT_STUB_API_BASE_URL is set.")
|
||||
if self.agent_stub_grpc_bind_address is not None:
|
||||
if self.agent_stub_url is None:
|
||||
raise ValueError("DIFY_AGENT_STUB_URL is required when DIFY_AGENT_STUB_GRPC_BIND_ADDRESS is set.")
|
||||
if not parse_agent_stub_endpoint(self.agent_stub_url).is_grpc:
|
||||
raise ValueError("DIFY_AGENT_STUB_GRPC_BIND_ADDRESS requires a grpc:// DIFY_AGENT_STUB_URL.")
|
||||
if self.dify_api_base_url is not None and self.dify_api_inner_api_key is None:
|
||||
raise ValueError("DIFY_AGENT_DIFY_API_INNER_API_KEY is required when DIFY_AGENT_DIFY_API_BASE_URL is set.")
|
||||
if self.agent_stub_api_base_url is None:
|
||||
raise ValueError(
|
||||
"DIFY_AGENT_STUB_API_BASE_URL is required when DIFY_AGENT_STUB_GRPC_BIND_ADDRESS is set."
|
||||
)
|
||||
if not parse_agent_stub_endpoint(self.agent_stub_api_base_url).is_grpc:
|
||||
raise ValueError("DIFY_AGENT_STUB_GRPC_BIND_ADDRESS requires a grpc:// DIFY_AGENT_STUB_API_BASE_URL.")
|
||||
return self
|
||||
|
||||
def create_agent_stub_token_codec(self) -> AgentStubTokenCodec | None:
|
||||
@ -141,11 +138,11 @@ class ServerSettings(BaseSettings):
|
||||
|
||||
def create_agent_stub_file_request_handler(self) -> DifyApiAgentStubFileRequestHandler | None:
|
||||
"""Return the Dify API file bridge when both Dify API settings are configured."""
|
||||
if self.dify_api_base_url is None or self.dify_api_inner_api_key is None:
|
||||
if self.inner_api_key is None:
|
||||
return None
|
||||
return DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url=self.dify_api_base_url,
|
||||
dify_api_inner_api_key=self.dify_api_inner_api_key,
|
||||
inner_api_url=self.inner_api_url,
|
||||
inner_api_key=self.inner_api_key,
|
||||
)
|
||||
|
||||
def create_agent_stub_drive_request_handler(self) -> DifyApiAgentStubDriveRequestHandler | None:
|
||||
@ -154,11 +151,11 @@ class ServerSettings(BaseSettings):
|
||||
Drive manifest and commit requests should honor the same outbound timeout
|
||||
settings as the server's other trusted Dify API HTTP calls.
|
||||
"""
|
||||
if self.dify_api_base_url is None or self.dify_api_inner_api_key is None:
|
||||
if self.inner_api_key is None:
|
||||
return None
|
||||
return DifyApiAgentStubDriveRequestHandler(
|
||||
dify_api_base_url=self.dify_api_base_url,
|
||||
dify_api_inner_api_key=self.dify_api_inner_api_key,
|
||||
inner_api_url=self.inner_api_url,
|
||||
inner_api_key=self.inner_api_key,
|
||||
timeout=self.create_outbound_http_timeout(),
|
||||
)
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
|
||||
|
||||
def test_list_drive_from_environment_returns_manifest_json_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
@ -56,7 +56,7 @@ def test_list_drive_from_environment_returns_manifest_json_model(monkeypatch: py
|
||||
|
||||
|
||||
def test_list_drive_from_environment_returns_human_readable_listing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
@ -99,7 +99,7 @@ def test_pull_drive_from_environment_writes_files_under_drive_base(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
@ -128,7 +128,7 @@ def test_pull_drive_from_environment_writes_files_under_drive_base(
|
||||
lambda **_kwargs: b"hello world",
|
||||
)
|
||||
|
||||
results = pull_drive_from_environment(prefix="skills/", drive_base=str(tmp_path))
|
||||
results = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path))
|
||||
|
||||
assert results == [tmp_path / "skills" / "example" / "SKILL.md"]
|
||||
assert results[0].read_bytes() == b"hello world"
|
||||
@ -146,7 +146,7 @@ def test_pull_drive_from_environment_auto_extracts_skill_archive(
|
||||
archive.writestr("nested/helper.py", "print('x')\n")
|
||||
archive_bytes = archive_buffer.getvalue()
|
||||
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
|
||||
@ -169,7 +169,7 @@ def test_pull_drive_from_environment_auto_extracts_skill_archive(
|
||||
lambda **_kwargs: archive_bytes,
|
||||
)
|
||||
|
||||
results = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path))
|
||||
results = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
|
||||
|
||||
archive_path = tmp_path / "skills" / "foo" / ".DIFY-SKILL-FULL.zip"
|
||||
assert results == [archive_path]
|
||||
@ -182,7 +182,7 @@ def test_pull_drive_from_environment_rejects_traversal_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
|
||||
@ -202,7 +202,7 @@ def test_pull_drive_from_environment_rejects_traversal_keys(
|
||||
)
|
||||
|
||||
with pytest.raises(AgentStubValidationError, match="outside the drive base"):
|
||||
_ = pull_drive_from_environment(prefix="", drive_base=str(tmp_path))
|
||||
_ = pull_drive_from_environment(targets=[""], drive_base=str(tmp_path))
|
||||
|
||||
|
||||
def test_pull_drive_from_environment_rejects_skill_archive_path_traversal(
|
||||
@ -215,7 +215,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_path_traversal(
|
||||
archive.writestr("../escape.txt", "escape")
|
||||
archive_bytes = archive_buffer.getvalue()
|
||||
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
|
||||
@ -239,7 +239,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_path_traversal(
|
||||
)
|
||||
|
||||
with pytest.raises(AgentStubValidationError, match="path traversal"):
|
||||
_ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path))
|
||||
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
|
||||
assert not (tmp_path / "skills" / "foo" / "SKILL.md").exists()
|
||||
|
||||
|
||||
@ -252,7 +252,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_absolute_entry(
|
||||
archive.writestr("/escape.txt", "escape")
|
||||
archive_bytes = archive_buffer.getvalue()
|
||||
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
|
||||
@ -276,7 +276,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_absolute_entry(
|
||||
)
|
||||
|
||||
with pytest.raises(AgentStubValidationError, match="absolute path"):
|
||||
_ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path))
|
||||
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
|
||||
|
||||
|
||||
def test_pull_drive_from_environment_rejects_skill_archive_symlink_entry(
|
||||
@ -290,7 +290,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_symlink_entry(
|
||||
archive.writestr(symlink_info, "outside.txt")
|
||||
archive_bytes = archive_buffer.getvalue()
|
||||
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
|
||||
@ -314,7 +314,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_symlink_entry(
|
||||
)
|
||||
|
||||
with pytest.raises(AgentStubValidationError, match="symlink entry"):
|
||||
_ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path))
|
||||
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
|
||||
|
||||
|
||||
def test_pull_drive_from_environment_rejects_invalid_skill_archive(
|
||||
@ -323,7 +323,7 @@ def test_pull_drive_from_environment_rejects_invalid_skill_archive(
|
||||
) -> None:
|
||||
archive_bytes = b"not-a-zip"
|
||||
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
|
||||
@ -347,14 +347,14 @@ def test_pull_drive_from_environment_rejects_invalid_skill_archive(
|
||||
)
|
||||
|
||||
with pytest.raises(AgentStubTransferError, match="downloaded skill archive is invalid"):
|
||||
_ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path))
|
||||
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
|
||||
|
||||
|
||||
def test_pull_drive_from_environment_rejects_missing_download_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
|
||||
@ -373,14 +373,14 @@ def test_pull_drive_from_environment_rejects_missing_download_url(
|
||||
)
|
||||
|
||||
with pytest.raises(AgentStubValidationError, match="missing download_url"):
|
||||
_ = pull_drive_from_environment(prefix="skills/", drive_base=str(tmp_path))
|
||||
_ = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path))
|
||||
|
||||
|
||||
def test_pull_drive_from_environment_rejects_size_mismatch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
|
||||
@ -404,13 +404,92 @@ def test_pull_drive_from_environment_rejects_size_mismatch(
|
||||
)
|
||||
|
||||
with pytest.raises(AgentStubTransferError, match="size mismatch"):
|
||||
_ = pull_drive_from_environment(prefix="skills/", drive_base=str(tmp_path))
|
||||
_ = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path))
|
||||
|
||||
|
||||
def test_pull_drive_from_environment_requests_multiple_targets_and_deduplicates_overlaps(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
captured_prefixes: list[str] = []
|
||||
|
||||
def fake_manifest(**kwargs):
|
||||
captured_prefixes.append(kwargs["prefix"])
|
||||
if kwargs["prefix"] == "skills/foo":
|
||||
return AgentStubDriveManifestResponse(
|
||||
items=[
|
||||
AgentStubDriveItem(
|
||||
key="skills/foo/SKILL.md",
|
||||
size=5,
|
||||
hash=None,
|
||||
mime_type="text/markdown",
|
||||
file_kind="tool_file",
|
||||
file_id="tool-file-1",
|
||||
download_url="https://files.example.com/skill-md",
|
||||
)
|
||||
]
|
||||
)
|
||||
return AgentStubDriveManifestResponse(
|
||||
items=[
|
||||
AgentStubDriveItem(
|
||||
key="skills/foo/SKILL.md",
|
||||
size=5,
|
||||
hash=None,
|
||||
mime_type="text/markdown",
|
||||
file_kind="tool_file",
|
||||
file_id="tool-file-1",
|
||||
download_url="https://files.example.com/skill-md",
|
||||
),
|
||||
AgentStubDriveItem(
|
||||
key="files/a.txt",
|
||||
size=1,
|
||||
hash=None,
|
||||
mime_type="text/plain",
|
||||
file_kind="tool_file",
|
||||
file_id="tool-file-2",
|
||||
download_url="https://files.example.com/a-txt",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
downloaded_urls: list[str] = []
|
||||
monkeypatch.setattr("dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", fake_manifest)
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync",
|
||||
lambda *, download_url: (
|
||||
downloaded_urls.append(download_url) or (b"hello" if download_url.endswith("skill-md") else b"a")
|
||||
),
|
||||
)
|
||||
|
||||
results = pull_drive_from_environment(targets=["skills/foo", "files/a.txt"], drive_base=str(tmp_path))
|
||||
|
||||
assert captured_prefixes == ["skills/foo", "files/a.txt"]
|
||||
assert results == [tmp_path / "files" / "a.txt", tmp_path / "skills" / "foo" / "SKILL.md"]
|
||||
assert downloaded_urls == ["https://files.example.com/a-txt", "https://files.example.com/skill-md"]
|
||||
|
||||
|
||||
def test_pull_drive_from_environment_without_targets_preserves_whole_drive_pull(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
captured_prefixes: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
|
||||
lambda **kwargs: captured_prefixes.append(kwargs["prefix"]) or AgentStubDriveManifestResponse(items=[]),
|
||||
)
|
||||
|
||||
assert pull_drive_from_environment(drive_base=str(tmp_path)) == []
|
||||
assert captured_prefixes == [""]
|
||||
|
||||
|
||||
def test_push_drive_from_environment_commits_single_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
source = tmp_path / "report.pdf"
|
||||
source.write_bytes(b"report")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._drive.upload_tool_file_resource_from_environment",
|
||||
@ -448,6 +527,8 @@ def test_push_drive_from_environment_commits_single_file(monkeypatch: pytest.Mon
|
||||
"key": "files/report.pdf",
|
||||
"file_ref": {"kind": "tool_file", "id": "tool-file-1"},
|
||||
"value_owned_by_drive": True,
|
||||
"is_skill": False,
|
||||
"skill_metadata": None,
|
||||
}
|
||||
|
||||
|
||||
@ -457,7 +538,7 @@ def test_push_drive_from_environment_requires_skill_md_for_non_recursive_directo
|
||||
) -> None:
|
||||
skill_dir = tmp_path / "skill"
|
||||
skill_dir.mkdir()
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
with pytest.raises(AgentStubValidationError, match="SKILL.md"):
|
||||
@ -472,7 +553,7 @@ def test_push_drive_from_environment_standardizes_non_recursive_skill_directory(
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text("# Example\n", encoding="utf-8")
|
||||
(skill_dir / "helper.py").write_text("print('x')\n", encoding="utf-8")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
uploaded_paths: list[str] = []
|
||||
@ -527,7 +608,7 @@ def test_push_drive_from_environment_non_recursive_archive_excludes_transient_en
|
||||
pycache_dir = skill_dir / "__pycache__"
|
||||
pycache_dir.mkdir()
|
||||
(pycache_dir / "helper.pyc").write_bytes(b"compiled")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
archive_entries: list[str] = []
|
||||
@ -578,7 +659,7 @@ def test_push_drive_from_environment_non_recursive_rejects_symlinked_archive_ent
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("outside", encoding="utf-8")
|
||||
(skill_dir / "linked.txt").symlink_to(outside)
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
with pytest.raises(AgentStubValidationError, match="symlink"):
|
||||
@ -594,7 +675,7 @@ def test_push_drive_from_environment_rejects_symlinked_recursive_files(
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("outside", encoding="utf-8")
|
||||
(root / "linked.txt").symlink_to(outside)
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
with pytest.raises(AgentStubValidationError, match="symlink"):
|
||||
@ -611,7 +692,7 @@ def test_push_drive_from_environment_recursive_keeps_user_files_that_skill_packa
|
||||
node_modules_dir = root / "node_modules"
|
||||
node_modules_dir.mkdir()
|
||||
(node_modules_dir / "module.js").write_text("export default 1\n", encoding="utf-8")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
uploaded_paths: list[str] = []
|
||||
|
||||
@ -25,7 +25,7 @@ def test_upload_file_from_environment_requests_signed_url_and_normalizes_output(
|
||||
) -> None:
|
||||
source = tmp_path / "report.pdf"
|
||||
source.write_bytes(b"report-bytes")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
@ -68,7 +68,7 @@ def test_upload_tool_file_resource_from_environment_preserves_tool_file_id(
|
||||
) -> None:
|
||||
source = tmp_path / "report.pdf"
|
||||
source.write_bytes(b"report-bytes")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
@ -96,7 +96,7 @@ def test_download_file_from_environment_saves_bytes_and_renames_on_collision(
|
||||
target_dir = tmp_path / "downloads"
|
||||
target_dir.mkdir()
|
||||
(target_dir / "report.pdf").write_bytes(b"existing")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
@ -133,7 +133,7 @@ def test_download_file_from_environment_sanitizes_server_filename(
|
||||
) -> None:
|
||||
target_dir = tmp_path / "downloads"
|
||||
target_dir.mkdir()
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
@ -171,7 +171,7 @@ def test_upload_file_from_environment_rejects_non_canonical_reference(
|
||||
) -> None:
|
||||
source = tmp_path / "report.pdf"
|
||||
source.write_bytes(b"report-bytes")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
@ -193,7 +193,7 @@ def test_upload_tool_file_resource_from_environment_rejects_missing_id(
|
||||
) -> None:
|
||||
source = tmp_path / "report.pdf"
|
||||
source.write_bytes(b"report-bytes")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
|
||||
@ -26,7 +26,7 @@ def test_cli_connect_reports_missing_environment_variables(capsys: pytest.Captur
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 2
|
||||
assert "DIFY_AGENT_STUB_URL" in captured.err
|
||||
assert "DIFY_AGENT_STUB_API_BASE_URL" in captured.err
|
||||
assert "DIFY_AGENT_STUB_AUTH_JWE" in captured.err
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ def test_cli_connect_supports_json_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
def fake_connect_from_environment(*, argv: list[str]) -> AgentStubConnectResponse:
|
||||
@ -53,7 +53,7 @@ def test_cli_unknown_command_auto_forwards_when_agent_stub_env_is_present(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
def fake_connect_from_environment(*, argv: list[str]) -> AgentStubConnectResponse:
|
||||
@ -78,7 +78,7 @@ def test_cli_unknown_command_reports_missing_environment_variables(
|
||||
assert exc_info.value.code == 2
|
||||
assert "Usage: dify-agent" in captured.out
|
||||
assert "connect" in captured.out
|
||||
assert "DIFY_AGENT_STUB_URL" in captured.err
|
||||
assert "DIFY_AGENT_STUB_API_BASE_URL" in captured.err
|
||||
assert "DIFY_AGENT_STUB_AUTH_JWE" in captured.err
|
||||
|
||||
|
||||
@ -92,11 +92,11 @@ def test_cli_connect_help_routes_to_typer_help(capsys: pytest.CaptureFixture[str
|
||||
assert "--json" in captured.out
|
||||
|
||||
|
||||
def test_cli_reports_invalid_agent_stub_url_environment_value(
|
||||
def test_cli_reports_invalid_agent_stub_api_base_url_environment_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub?x=1")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub?x=1")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
@ -104,7 +104,7 @@ def test_cli_reports_invalid_agent_stub_url_environment_value(
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 2
|
||||
assert "invalid DIFY_AGENT_STUB_URL" in captured.err
|
||||
assert "invalid DIFY_AGENT_STUB_API_BASE_URL" in captured.err
|
||||
assert "query string or fragment" in captured.err
|
||||
|
||||
|
||||
@ -117,13 +117,13 @@ def test_cli_reports_invalid_agent_stub_url_environment_value(
|
||||
("grpc://agent.example.com", "explicit port"),
|
||||
],
|
||||
)
|
||||
def test_cli_reports_structurally_invalid_agent_stub_url_environment_value(
|
||||
def test_cli_reports_structurally_invalid_agent_stub_api_base_url_environment_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
invalid_url: str,
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", invalid_url)
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", invalid_url)
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
@ -131,14 +131,14 @@ def test_cli_reports_structurally_invalid_agent_stub_url_environment_value(
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 2
|
||||
assert "invalid DIFY_AGENT_STUB_URL" in captured.err
|
||||
assert "invalid DIFY_AGENT_STUB_API_BASE_URL" in captured.err
|
||||
assert expected_message in captured.err
|
||||
|
||||
|
||||
def test_cli_connect_accepts_grpc_agent_stub_url(
|
||||
def test_cli_connect_accepts_grpc_agent_stub_api_base_url(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "grpc://agent.example.com:9091")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "grpc://agent.example.com:9091")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
def fake_connect_from_environment(*, argv: list[str]) -> AgentStubConnectResponse:
|
||||
@ -252,7 +252,10 @@ def test_cli_drive_pull_prints_downloaded_paths(
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
|
||||
lambda *, prefix, drive_base: [Path(drive_base) / prefix / "SKILL.md", Path(drive_base) / prefix / "helper.py"],
|
||||
lambda *, targets, drive_base: [
|
||||
Path(drive_base) / targets[0] / "SKILL.md",
|
||||
Path(drive_base) / targets[0] / "helper.py",
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
@ -266,6 +269,83 @@ def test_cli_drive_pull_prints_downloaded_paths(
|
||||
]
|
||||
|
||||
|
||||
def test_cli_drive_pull_forwards_multiple_targets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
def fake_pull_drive_from_environment(*, targets, drive_base):
|
||||
captured_kwargs["targets"] = targets
|
||||
captured_kwargs["drive_base"] = drive_base
|
||||
return [Path(drive_base) / "skills" / "foo" / "SKILL.md"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
|
||||
fake_pull_drive_from_environment,
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["drive", "pull", "skills/foo", "files/a.txt", "--drive-base", "/tmp/drive"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 0
|
||||
assert captured_kwargs == {"targets": ["skills/foo", "files/a.txt"], "drive_base": "/tmp/drive"}
|
||||
assert captured.out.strip() == "/tmp/drive/skills/foo/SKILL.md"
|
||||
|
||||
|
||||
def test_cli_drive_pull_uses_environment_drive_base_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_DRIVE_BASE", "/env/drive")
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
def fake_pull_drive_from_environment(*, targets, drive_base):
|
||||
captured_kwargs["targets"] = targets
|
||||
captured_kwargs["drive_base"] = drive_base
|
||||
return [Path(drive_base) / "skills" / "foo" / "SKILL.md"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
|
||||
fake_pull_drive_from_environment,
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["drive", "pull", "skills/foo"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 0
|
||||
assert captured_kwargs == {"targets": ["skills/foo"], "drive_base": "/env/drive"}
|
||||
assert captured.out.strip() == "/env/drive/skills/foo/SKILL.md"
|
||||
|
||||
|
||||
def test_cli_drive_pull_keeps_historical_drive_base_when_env_is_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.delenv("DIFY_AGENT_STUB_DRIVE_BASE", raising=False)
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
def fake_pull_drive_from_environment(*, targets, drive_base):
|
||||
captured_kwargs["targets"] = targets
|
||||
captured_kwargs["drive_base"] = drive_base
|
||||
return [Path(drive_base) / "skills" / "foo" / "SKILL.md"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
|
||||
fake_pull_drive_from_environment,
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["drive", "pull", "skills/foo"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 0
|
||||
assert captured_kwargs == {"targets": ["skills/foo"], "drive_base": "/mnt/drive"}
|
||||
assert captured.out.strip() == "/mnt/drive/skills/foo/SKILL.md"
|
||||
|
||||
|
||||
def test_cli_drive_push_prints_commit_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
||||
@ -66,7 +66,9 @@ def test_connect_agent_stub_sync_posts_connections_request_with_authorization()
|
||||
|
||||
|
||||
def test_connect_agent_stub_sync_rejects_invalid_base_url() -> None:
|
||||
with pytest.raises(AgentStubValidationError, match="invalid DIFY_AGENT_STUB_URL|invalid Agent Stub base URL"):
|
||||
with pytest.raises(
|
||||
AgentStubValidationError, match="invalid DIFY_AGENT_STUB_API_BASE_URL|invalid Agent Stub base URL"
|
||||
):
|
||||
_ = connect_agent_stub_sync(
|
||||
url="https://agent.example.com/agent-stub?x=1",
|
||||
auth_jwe="test-jwe",
|
||||
|
||||
@ -13,11 +13,12 @@ from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubDriveFileRef,
|
||||
AgentStubFileMapping,
|
||||
agent_stub_connections_url,
|
||||
agent_stub_drive_base_for_ref,
|
||||
agent_stub_drive_commit_url,
|
||||
agent_stub_drive_manifest_url,
|
||||
agent_stub_file_download_request_url,
|
||||
agent_stub_file_upload_request_url,
|
||||
normalize_agent_stub_url,
|
||||
normalize_agent_stub_api_base_url,
|
||||
parse_agent_stub_endpoint,
|
||||
)
|
||||
|
||||
@ -36,6 +37,12 @@ def test_agent_stub_connections_url_handles_trailing_slash_and_no_trailing_slash
|
||||
)
|
||||
|
||||
|
||||
def test_agent_stub_connections_url_normalizes_service_root_to_agent_stub_base() -> None:
|
||||
assert agent_stub_connections_url("https://agent.example.com") == (
|
||||
"https://agent.example.com/agent-stub/connections"
|
||||
)
|
||||
|
||||
|
||||
def test_agent_stub_file_request_urls_handle_trailing_slash() -> None:
|
||||
assert agent_stub_file_upload_request_url("https://agent.example.com/agent-stub/") == (
|
||||
"https://agent.example.com/agent-stub/files/upload-request"
|
||||
@ -54,23 +61,52 @@ def test_agent_stub_drive_request_urls_handle_trailing_slash() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_agent_stub_url_rejects_query_and_fragment() -> None:
|
||||
def test_agent_stub_drive_base_for_ref_uses_fixed_mount_with_drive_ref() -> None:
|
||||
assert agent_stub_drive_base_for_ref("agent-1") == "/mnt/drive/agent-1"
|
||||
assert agent_stub_drive_base_for_ref("shared/drive") == "/mnt/drive/shared/drive"
|
||||
|
||||
|
||||
def test_agent_stub_drive_base_for_ref_uses_default_without_drive_ref() -> None:
|
||||
assert agent_stub_drive_base_for_ref(None) == "/mnt/drive"
|
||||
assert agent_stub_drive_base_for_ref(" ") == "/mnt/drive"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"drive_ref",
|
||||
["/agent-1", "../agent-1", "agent-1/..", "agent-1/./files", "agent-1//files"],
|
||||
)
|
||||
def test_agent_stub_drive_base_for_ref_rejects_unsafe_refs(drive_ref: str) -> None:
|
||||
with pytest.raises(ValueError, match="safe relative path"):
|
||||
_ = agent_stub_drive_base_for_ref(drive_ref)
|
||||
|
||||
|
||||
def test_normalize_agent_stub_api_base_url_rejects_query_and_fragment() -> None:
|
||||
with pytest.raises(ValueError, match="query string or fragment"):
|
||||
_ = normalize_agent_stub_url("https://agent.example.com/agent-stub?x=1")
|
||||
_ = normalize_agent_stub_api_base_url("https://agent.example.com/agent-stub?x=1")
|
||||
|
||||
with pytest.raises(ValueError, match="query string or fragment"):
|
||||
_ = normalize_agent_stub_url("https://agent.example.com/agent-stub#fragment")
|
||||
_ = normalize_agent_stub_api_base_url("https://agent.example.com/agent-stub#fragment")
|
||||
|
||||
|
||||
def test_normalize_agent_stub_api_base_url_accepts_service_root_or_agent_stub_root_only() -> None:
|
||||
assert normalize_agent_stub_api_base_url("https://agent.example.com") == "https://agent.example.com/agent-stub"
|
||||
assert normalize_agent_stub_api_base_url("https://agent.example.com/agent-stub/") == (
|
||||
"https://agent.example.com/agent-stub"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="empty or /agent-stub"):
|
||||
_ = normalize_agent_stub_api_base_url("https://agent.example.com/foo")
|
||||
|
||||
|
||||
def test_parse_agent_stub_endpoint_rejects_invalid_schemes_and_missing_host() -> None:
|
||||
with pytest.raises(ValueError, match="http, https, or grpc"):
|
||||
_ = normalize_agent_stub_url("not-a-url")
|
||||
_ = normalize_agent_stub_api_base_url("not-a-url")
|
||||
|
||||
with pytest.raises(ValueError, match="http, https, or grpc"):
|
||||
_ = normalize_agent_stub_url("ftp://agent.example.com/agent-stub")
|
||||
_ = normalize_agent_stub_api_base_url("ftp://agent.example.com/agent-stub")
|
||||
|
||||
with pytest.raises(ValueError, match="include a host"):
|
||||
_ = normalize_agent_stub_url("https:///agent-stub")
|
||||
_ = normalize_agent_stub_api_base_url("https:///agent-stub")
|
||||
|
||||
|
||||
def test_parse_agent_stub_endpoint_accepts_grpc_host_and_port() -> None:
|
||||
@ -128,8 +164,8 @@ def test_agent_stub_drive_commit_request_validates_file_refs() -> None:
|
||||
with pytest.raises(ValidationError, match="tool_file"):
|
||||
_ = AgentStubDriveFileRef(kind="bad_kind", id="tool-file-1") # pyright: ignore[reportArgumentType]
|
||||
|
||||
with pytest.raises(ValidationError, match="file_ref"):
|
||||
_ = AgentStubDriveCommitItem.model_validate({"key": "skills/example/SKILL.md"})
|
||||
item_without_file_ref = AgentStubDriveCommitItem.model_validate({"key": "skills/example/SKILL.md"})
|
||||
assert item_without_file_ref.file_ref is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transfer_method", ["tool_file", "local_file", "datasource_file"])
|
||||
|
||||
@ -29,7 +29,7 @@ def _execution_context() -> DifyExecutionContextLayerConfig:
|
||||
def test_create_agent_stub_app_exposes_same_stub_routes_as_module_app() -> None:
|
||||
stub_app_module = importlib.import_module("dify_agent.agent_stub.server.app")
|
||||
settings = ServerSettings(
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
)
|
||||
|
||||
@ -60,10 +60,10 @@ def test_create_agent_stub_app_can_serve_requests() -> None:
|
||||
|
||||
def test_create_agent_stub_app_wires_configured_file_handler_for_upload_requests(monkeypatch) -> None:
|
||||
settings = ServerSettings(
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
assert token_codec is not None
|
||||
@ -94,10 +94,10 @@ def test_create_agent_stub_app_wires_configured_file_handler_for_upload_requests
|
||||
|
||||
def test_create_agent_stub_app_wires_configured_drive_handler_for_manifest_requests(monkeypatch) -> None:
|
||||
settings = ServerSettings(
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
assert token_codec is not None
|
||||
|
||||
@ -71,8 +71,8 @@ def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_manifes
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
drive_handler = DifyApiAgentStubDriveRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -119,8 +119,8 @@ def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_commit(
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
drive_handler = DifyApiAgentStubDriveRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -142,8 +142,8 @@ def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_commit(
|
||||
|
||||
def test_dify_api_agent_stub_drive_handler_rejects_missing_agent_id() -> None:
|
||||
drive_handler = DifyApiAgentStubDriveRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
principal = _principal()
|
||||
principal.execution_context = principal.execution_context.model_copy(update={"agent_id": None})
|
||||
@ -162,8 +162,8 @@ def test_dify_api_agent_stub_drive_handler_rejects_missing_agent_id() -> None:
|
||||
|
||||
def test_dify_api_agent_stub_drive_handler_rejects_missing_user_id_for_commit() -> None:
|
||||
drive_handler = DifyApiAgentStubDriveRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
principal = _principal()
|
||||
principal.execution_context = principal.execution_context.model_copy(update={"user_id": None})
|
||||
@ -196,8 +196,8 @@ def test_dify_api_agent_stub_drive_handler_maps_invalid_json_response(monkeypatc
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
drive_handler = DifyApiAgentStubDriveRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -218,8 +218,8 @@ def test_dify_api_agent_stub_drive_handler_rejects_malformed_success_payload(mon
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
drive_handler = DifyApiAgentStubDriveRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -240,8 +240,8 @@ def test_dify_api_agent_stub_drive_handler_preserves_non_2xx_detail(monkeypatch)
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
drive_handler = DifyApiAgentStubDriveRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
|
||||
@ -59,8 +59,8 @@ def test_dify_api_agent_stub_file_handler_injects_execution_context_for_upload(m
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -97,8 +97,8 @@ def test_dify_api_agent_stub_file_handler_injects_execution_context_for_download
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -115,8 +115,8 @@ def test_dify_api_agent_stub_file_handler_injects_execution_context_for_download
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_rejects_missing_user_id() -> None:
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
principal = _principal()
|
||||
principal.execution_context = principal.execution_context.model_copy(update={"user_id": None})
|
||||
@ -141,8 +141,8 @@ def test_dify_api_agent_stub_file_handler_maps_non_2xx_response(monkeypatch) ->
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -166,8 +166,8 @@ def test_dify_api_agent_stub_file_handler_maps_error_envelope(monkeypatch) -> No
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -193,8 +193,8 @@ def test_dify_api_agent_stub_file_handler_rejects_upload_response_missing_url(mo
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -218,8 +218,8 @@ def test_dify_api_agent_stub_file_handler_rejects_invalid_download_response_sche
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
|
||||
@ -43,7 +43,7 @@ def test_stub_server_cli_passes_explicit_uvicorn_settings(monkeypatch) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_stub_server_cli_switches_to_grpc_when_agent_stub_url_uses_grpc(monkeypatch) -> None:
|
||||
def test_stub_server_cli_switches_to_grpc_when_agent_stub_api_base_url_uses_grpc(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_serve_grpc(*, settings, host, port) -> None:
|
||||
@ -51,7 +51,7 @@ def test_stub_server_cli_switches_to_grpc_when_agent_stub_url_uses_grpc(monkeypa
|
||||
|
||||
monkeypatch.setattr(cli_module, "_serve_grpc", fake_serve_grpc)
|
||||
monkeypatch.setattr(
|
||||
cli_module, "ServerSettings", lambda: type("Settings", (), {"agent_stub_url": "grpc://agent:9091"})()
|
||||
cli_module, "ServerSettings", lambda: type("Settings", (), {"agent_stub_api_base_url": "grpc://agent:9091"})()
|
||||
)
|
||||
|
||||
cli_module.main(["--host", "0.0.0.0", "--port", "9092"])
|
||||
@ -84,7 +84,7 @@ def test_serve_grpc_derives_default_bind_target_and_closes_server(monkeypatch) -
|
||||
"Settings",
|
||||
(),
|
||||
{
|
||||
"agent_stub_url": "grpc://agent.example.com:9091",
|
||||
"agent_stub_api_base_url": "grpc://agent.example.com:9091",
|
||||
"agent_stub_grpc_bind_address": None,
|
||||
"create_agent_stub_token_codec": lambda self: "token-codec",
|
||||
"create_agent_stub_file_request_handler": lambda self: "file-handler",
|
||||
@ -124,7 +124,7 @@ def test_serve_grpc_applies_cli_host_port_overrides(monkeypatch) -> None:
|
||||
"Settings",
|
||||
(),
|
||||
{
|
||||
"agent_stub_url": "grpc://agent.example.com:9091",
|
||||
"agent_stub_api_base_url": "grpc://agent.example.com:9091",
|
||||
"agent_stub_grpc_bind_address": "127.0.0.1:9191",
|
||||
"create_agent_stub_token_codec": lambda self: None,
|
||||
"create_agent_stub_file_request_handler": lambda self: None,
|
||||
|
||||
@ -5,12 +5,10 @@ from pydantic import ValidationError
|
||||
|
||||
from dify_agent.layers.drive import (
|
||||
DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
DifyDriveFileConfig,
|
||||
DifyDriveLayerConfig,
|
||||
DifyDriveSkillConfig,
|
||||
)
|
||||
from dify_agent.layers.drive.layer import DifyDriveLayer
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
|
||||
|
||||
def test_type_id_is_frozen_contract() -> None:
|
||||
@ -24,23 +22,24 @@ def test_layer_config_round_trips_manifest_entries() -> None:
|
||||
"drive_ref": "agent-019e9112",
|
||||
"skills": [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFP documents step by step.",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
}
|
||||
],
|
||||
"files": [{"name": "sample.pdf", "key": "files/sample.pdf", "size": 1024, "mime_type": "application/pdf"}],
|
||||
"mentioned_skill_keys": ["tender-analyzer/SKILL.md"],
|
||||
"mentioned_file_keys": ["files/sample.pdf"],
|
||||
}
|
||||
)
|
||||
|
||||
dumped = config.model_dump(mode="json")
|
||||
assert dumped["drive_ref"] == "agent-019e9112"
|
||||
assert "drive_base" not in dumped
|
||||
assert dumped["skills"][0]["skill_md_key"] == "tender-analyzer/SKILL.md"
|
||||
assert dumped["files"][0]["key"] == "files/sample.pdf"
|
||||
# the declaration is an index only — there is no field that could carry file content
|
||||
assert dumped["mentioned_file_keys"] == ["files/sample.pdf"]
|
||||
assert "content" not in DifyDriveSkillConfig.model_fields
|
||||
assert "content" not in DifyDriveFileConfig.model_fields
|
||||
|
||||
|
||||
def test_layer_config_rejects_unknown_fields() -> None:
|
||||
@ -48,11 +47,13 @@ def test_layer_config_rejects_unknown_fields() -> None:
|
||||
DifyDriveLayerConfig.model_validate({"drive_ref": "agent-1", "skill_md_body": "# inline content"})
|
||||
|
||||
|
||||
def test_inert_layer_is_registered_and_constructible_from_config() -> None:
|
||||
providers = create_default_layer_providers()
|
||||
provider = next(p for p in providers if p.type_id == DIFY_DRIVE_LAYER_TYPE_ID)
|
||||
|
||||
layer = provider.create_layer({"drive_ref": "agent-1", "skills": [], "files": []})
|
||||
def test_drive_layer_is_registered_and_constructible_from_config() -> None:
|
||||
layer = DifyDriveLayer.from_config_with_settings(
|
||||
DifyDriveLayerConfig(drive_ref="agent-1", skills=[], mentioned_skill_keys=[], mentioned_file_keys=[]),
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="secret",
|
||||
)
|
||||
|
||||
assert isinstance(layer, DifyDriveLayer)
|
||||
assert layer.config.drive_ref == "agent-1"
|
||||
assert not hasattr(layer, "local_drive_base")
|
||||
|
||||
200
dify-agent/tests/local/dify_agent/layers/drive/test_layer.py
Normal file
200
dify-agent/tests/local/dify_agent/layers/drive/test_layer.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""Behavior tests for the runtime Dify drive layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from agenton.layers import EmptyRuntimeState, LayerConfig, NoLayerDeps, PlainLayer
|
||||
from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig
|
||||
from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError, _DriveManifestItem
|
||||
|
||||
|
||||
class _FakeExecutionContextConfig(LayerConfig):
|
||||
tenant_id: str
|
||||
|
||||
|
||||
class _FakeExecutionContextLayer(PlainLayer[NoLayerDeps, _FakeExecutionContextConfig, EmptyRuntimeState]):
|
||||
type_id = None
|
||||
|
||||
def __init__(self, tenant_id: str) -> None:
|
||||
self.config = _FakeExecutionContextConfig(tenant_id=tenant_id)
|
||||
|
||||
|
||||
def _build_layer(tmp_path: Path) -> DifyDriveLayer:
|
||||
layer = DifyDriveLayer.from_config_with_settings(
|
||||
DifyDriveLayerConfig(
|
||||
drive_ref="agent-1",
|
||||
skills=[
|
||||
DifyDriveSkillConfig(
|
||||
path="tender-analyzer",
|
||||
name="Tender Analyzer",
|
||||
description="Parses RFPs.",
|
||||
skill_md_key="tender-analyzer/SKILL.md",
|
||||
archive_key="tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
),
|
||||
DifyDriveSkillConfig(
|
||||
path="other-skill",
|
||||
name="Other Skill",
|
||||
description="Fallback catalog entry.",
|
||||
skill_md_key="other-skill/SKILL.md",
|
||||
archive_key=None,
|
||||
),
|
||||
],
|
||||
mentioned_skill_keys=["tender-analyzer/SKILL.md"],
|
||||
mentioned_file_keys=["files/report.pdf"],
|
||||
),
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="secret",
|
||||
)
|
||||
layer.bind_deps({"execution_context": _FakeExecutionContextLayer("tenant-1")})
|
||||
return layer
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_context_create_loads_mentioned_targets_into_prompt(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
layer = _build_layer(tmp_path)
|
||||
|
||||
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
|
||||
assert tenant_id == "tenant-1"
|
||||
assert targets == [("tender-analyzer/", False), ("files/report.pdf", True)]
|
||||
return [
|
||||
_DriveManifestItem(key="tender-analyzer/SKILL.md", download_url="https://files/skill-md"),
|
||||
_DriveManifestItem(key="files/report.pdf", download_url="https://files/report"),
|
||||
]
|
||||
|
||||
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
|
||||
assert {item.key for item in items} == {"files/report.pdf", "tender-analyzer/SKILL.md"}
|
||||
skill_path = tmp_path / "tender-analyzer" / "SKILL.md"
|
||||
skill_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
skill_path.write_text("# Tender Analyzer\nUse carefully.\n", encoding="utf-8")
|
||||
file_path = tmp_path / "files" / "report.pdf"
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_bytes(b"pdf")
|
||||
return {
|
||||
"tender-analyzer/SKILL.md": str(skill_path),
|
||||
"files/report.pdf": str(file_path),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
|
||||
monkeypatch.setattr(layer, "_download_items", _download_items)
|
||||
|
||||
await layer.on_context_create()
|
||||
|
||||
prompt = layer.build_prompt_context()
|
||||
assert "Loaded mentioned skills" in prompt
|
||||
assert "# Tender Analyzer\nUse carefully." in prompt
|
||||
assert f"files/report.pdf -> {tmp_path / 'files' / 'report.pdf'}" in prompt
|
||||
assert "Other available skills" in prompt
|
||||
assert "other-skill: Other Skill — Fallback catalog entry." in prompt
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_context_resume_loads_mentioned_targets_into_prompt(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
layer = _build_layer(tmp_path)
|
||||
|
||||
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
|
||||
assert tenant_id == "tenant-1"
|
||||
assert targets == [("tender-analyzer/", False), ("files/report.pdf", True)]
|
||||
return [
|
||||
_DriveManifestItem(key="tender-analyzer/SKILL.md", download_url="https://files/skill-md"),
|
||||
_DriveManifestItem(key="files/report.pdf", download_url="https://files/report"),
|
||||
]
|
||||
|
||||
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
|
||||
assert {item.key for item in items} == {"files/report.pdf", "tender-analyzer/SKILL.md"}
|
||||
skill_path = tmp_path / "tender-analyzer" / "SKILL.md"
|
||||
skill_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
skill_path.write_text("# Tender Analyzer\nUse carefully.\n", encoding="utf-8")
|
||||
file_path = tmp_path / "files" / "report.pdf"
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_bytes(b"pdf")
|
||||
return {
|
||||
"tender-analyzer/SKILL.md": str(skill_path),
|
||||
"files/report.pdf": str(file_path),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
|
||||
monkeypatch.setattr(layer, "_download_items", _download_items)
|
||||
|
||||
await layer.on_context_resume()
|
||||
|
||||
prompt = layer.build_prompt_context()
|
||||
assert "Loaded mentioned skills" in prompt
|
||||
assert "# Tender Analyzer\nUse carefully." in prompt
|
||||
assert f"files/report.pdf -> {tmp_path / 'files' / 'report.pdf'}" in prompt
|
||||
assert "Other available skills" in prompt
|
||||
assert "other-skill: Other Skill — Fallback catalog entry." in prompt
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_context_create_raises_when_mentioned_file_is_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
layer = _build_layer(tmp_path)
|
||||
|
||||
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
|
||||
del tenant_id, targets
|
||||
return [_DriveManifestItem(key="tender-analyzer/SKILL.md", download_url="https://files/skill-md")]
|
||||
|
||||
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
|
||||
del items
|
||||
skill_path = tmp_path / "tender-analyzer" / "SKILL.md"
|
||||
skill_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
skill_path.write_text("# Tender Analyzer\nUse carefully.\n", encoding="utf-8")
|
||||
return {"tender-analyzer/SKILL.md": str(skill_path)}
|
||||
|
||||
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
|
||||
monkeypatch.setattr(layer, "_download_items", _download_items)
|
||||
|
||||
with pytest.raises(DifyDriveLayerError, match="missing pulled file"):
|
||||
await layer.on_context_create()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_context_resume_raises_when_mentioned_targets_are_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
layer = _build_layer(tmp_path)
|
||||
|
||||
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
|
||||
del tenant_id, targets
|
||||
return []
|
||||
|
||||
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
|
||||
assert items == []
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
|
||||
monkeypatch.setattr(layer, "_download_items", _download_items)
|
||||
|
||||
with pytest.raises(DifyDriveLayerError, match="missing pulled file"):
|
||||
await layer.on_context_resume()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_context_create_raises_when_manifest_is_empty_for_mentioned_targets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
layer = _build_layer(tmp_path)
|
||||
|
||||
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
|
||||
del tenant_id, targets
|
||||
return []
|
||||
|
||||
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
|
||||
assert items == []
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
|
||||
monkeypatch.setattr(layer, "_download_items", _download_items)
|
||||
|
||||
with pytest.raises(DifyDriveLayerError, match="missing pulled file"):
|
||||
await layer.on_context_create()
|
||||
@ -56,8 +56,8 @@ def _knowledge_provider() -> LayerProvider[DifyKnowledgeBaseLayer]:
|
||||
layer_type=DifyKnowledgeBaseLayer,
|
||||
create=lambda config: DifyKnowledgeBaseLayer.from_config_with_settings(
|
||||
DifyKnowledgeBaseLayerConfig.model_validate(config),
|
||||
dify_api_inner_url="http://dify-api",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="http://dify-api",
|
||||
inner_api_key="inner-secret",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -8,7 +8,13 @@ import pytest
|
||||
|
||||
from agenton.compositor import Compositor, LayerNode, LayerProvider
|
||||
from agenton.layers import LifecycleState
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import AGENT_STUB_AUTH_JWE_ENV_VAR, AGENT_STUB_URL_ENV_VAR
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR,
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR,
|
||||
)
|
||||
from dify_agent.layers.drive import DifyDriveLayerConfig
|
||||
from dify_agent.layers.drive.layer import DifyDriveLayer
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.shell import (
|
||||
@ -231,6 +237,14 @@ def _execution_context_layer() -> DifyExecutionContextLayer:
|
||||
)
|
||||
|
||||
|
||||
def _drive_layer() -> DifyDriveLayer:
|
||||
return DifyDriveLayer.from_config_with_settings(
|
||||
DifyDriveLayerConfig(drive_ref="agent-1"),
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="secret",
|
||||
)
|
||||
|
||||
|
||||
def _shell_provider(*, client_factory: ShellctlClientFactory) -> LayerProvider[DifyShellLayer]:
|
||||
return LayerProvider.from_factory(
|
||||
layer_type=DifyShellLayer,
|
||||
@ -606,12 +620,12 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() ->
|
||||
DifyShellLayerConfig(),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
),
|
||||
)
|
||||
layer.deps = layer.deps_type(execution_context=_execution_context_layer())
|
||||
layer.deps = layer.deps_type(drive=_drive_layer(), execution_context=_execution_context_layer())
|
||||
tools = {tool.name: tool for tool in layer.tools}
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -629,8 +643,9 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() ->
|
||||
internal_run_calls = [call for call in client.run_calls if not call.script.endswith("\npwd")]
|
||||
|
||||
assert user_run_call.env == {
|
||||
AGENT_STUB_URL_ENV_VAR: "https://agent.example.com/agent-stub",
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub",
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR: f"token-for:tenant-1:{layer.runtime_state.session_id}",
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR: "/mnt/drive/agent-1",
|
||||
}
|
||||
assert internal_run_calls
|
||||
assert all(call.env is None for call in internal_run_calls)
|
||||
@ -721,8 +736,9 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads()
|
||||
del timeout
|
||||
assert cwd == "~/workspace/abc12ff"
|
||||
assert env == {
|
||||
AGENT_STUB_URL_ENV_VAR: "https://agent.example.com/agent-stub",
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub",
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff",
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR: "/mnt/drive/agent-1",
|
||||
}
|
||||
return _job_result("remote-upload", status=JobStatusName.EXITED, done=True, exit_code=0, output="{}")
|
||||
|
||||
@ -731,12 +747,12 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads()
|
||||
DifyShellLayerConfig(),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
),
|
||||
)
|
||||
layer.deps = layer.deps_type(execution_context=_execution_context_layer())
|
||||
layer.deps = layer.deps_type(drive=_drive_layer(), execution_context=_execution_context_layer())
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
@ -761,7 +777,7 @@ def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None:
|
||||
DifyShellLayerConfig(),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
),
|
||||
@ -791,7 +807,7 @@ def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency()
|
||||
DifyShellLayerConfig(),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
),
|
||||
|
||||
@ -84,7 +84,7 @@ def test_default_layer_providers_build_agent_stub_token_factory_from_agent_stub_
|
||||
|
||||
providers = create_default_layer_providers(
|
||||
shellctl_entrypoint="http://shellctl.example",
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_codec=codec,
|
||||
)
|
||||
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
|
||||
|
||||
@ -189,13 +189,12 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
run_retention_seconds=7,
|
||||
plugin_daemon_url="http://plugin-daemon",
|
||||
plugin_daemon_api_key="daemon-secret",
|
||||
dify_api_inner_url="http://dify-api",
|
||||
inner_api_url="http://dify-api",
|
||||
inner_api_key="inner-secret",
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_auth_token="shell-secret",
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
outbound_http_connect_timeout=1,
|
||||
outbound_http_read_timeout=2,
|
||||
outbound_http_write_timeout=3,
|
||||
@ -238,10 +237,10 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
)
|
||||
)
|
||||
assert isinstance(knowledge_layer, DifyKnowledgeBaseLayer)
|
||||
assert knowledge_layer.dify_api_inner_url == "http://dify-api"
|
||||
assert knowledge_layer.dify_api_inner_api_key == "inner-secret"
|
||||
assert knowledge_layer.inner_api_url == "http://dify-api"
|
||||
assert knowledge_layer.inner_api_key == "inner-secret"
|
||||
assert shell_layer.shellctl_entrypoint == "http://shellctl"
|
||||
assert shell_layer.agent_stub_url == "https://agent.example.com/agent-stub"
|
||||
assert shell_layer.agent_stub_api_base_url == "https://agent.example.com/agent-stub"
|
||||
shellctl_client = shell_layer.shellctl_client_factory("http://shellctl")
|
||||
assert isinstance(shellctl_client, ShellctlClient)
|
||||
assert shellctl_client.token == "shell-secret"
|
||||
@ -277,7 +276,7 @@ def test_create_app_wires_authenticated_agent_stub_connection_route(monkeypatch:
|
||||
fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch)
|
||||
settings = ServerSettings(
|
||||
redis_url="redis://example.invalid/0",
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
@ -303,10 +302,10 @@ def test_create_app_wires_authenticated_agent_stub_file_upload_route(monkeypatch
|
||||
fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch)
|
||||
settings = ServerSettings(
|
||||
redis_url="redis://example.invalid/0",
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
assert token_codec is not None
|
||||
@ -342,10 +341,10 @@ def test_create_app_wires_authenticated_agent_stub_drive_manifest_route(monkeypa
|
||||
fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch)
|
||||
settings = ServerSettings(
|
||||
redis_url="redis://example.invalid/0",
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
assert token_codec is not None
|
||||
@ -409,7 +408,7 @@ def test_create_app_starts_and_stops_agent_stub_grpc_server_for_grpc_url(monkeyp
|
||||
|
||||
settings = ServerSettings(
|
||||
redis_url="redis://example.invalid/0",
|
||||
agent_stub_url="grpc://agent.example.com:9091",
|
||||
agent_stub_api_base_url="grpc://agent.example.com:9091",
|
||||
agent_stub_grpc_bind_address="0.0.0.0:9191",
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
)
|
||||
@ -485,8 +484,8 @@ def test_create_dify_api_inner_http_client_uses_generic_outbound_httpx_construct
|
||||
def test_server_settings_use_generic_outbound_http_args_for_shared_clients() -> None:
|
||||
model_fields = ServerSettings.model_fields
|
||||
|
||||
assert "dify_api_inner_url" in model_fields
|
||||
assert "dify_api_inner_api_key" in model_fields
|
||||
assert "inner_api_url" in model_fields
|
||||
assert "inner_api_key" in model_fields
|
||||
assert "outbound_http_connect_timeout" in model_fields
|
||||
assert "outbound_http_read_timeout" in model_fields
|
||||
assert "outbound_http_write_timeout" in model_fields
|
||||
|
||||
@ -10,7 +10,11 @@ import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerProvider
|
||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||
from agenton.layers.base import LifecycleState
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import AGENT_STUB_AUTH_JWE_ENV_VAR, AGENT_STUB_URL_ENV_VAR
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR,
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR,
|
||||
)
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.shell import DifyShellLayerConfig
|
||||
@ -189,7 +193,7 @@ def _service(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
),
|
||||
@ -335,8 +339,9 @@ def test_upload_file_injects_agent_stub_env_and_returns_mapping() -> None:
|
||||
assert cwd == "~/workspace/abc12ff"
|
||||
assert timeout == 30.0
|
||||
assert env == {
|
||||
AGENT_STUB_URL_ENV_VAR: "https://agent.example.com/agent-stub",
|
||||
AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub",
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff",
|
||||
AGENT_STUB_DRIVE_BASE_ENV_VAR: "/mnt/drive",
|
||||
}
|
||||
assert 'dify-agent", "file", "upload"' in script
|
||||
return _job_result(
|
||||
|
||||
@ -49,12 +49,21 @@ def test_server_settings_defaults_shellctl_auth_token_to_none(
|
||||
|
||||
|
||||
def test_server_settings_reads_agent_stub_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub/")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub/")
|
||||
monkeypatch.setenv("DIFY_AGENT_SERVER_SECRET_KEY", _base64url_secret(secrets.token_bytes(32)))
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.agent_stub_url == "https://agent.example.com/agent-stub"
|
||||
assert settings.agent_stub_api_base_url == "https://agent.example.com/agent-stub"
|
||||
|
||||
|
||||
def test_server_settings_normalizes_agent_stub_service_root_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com")
|
||||
monkeypatch.setenv("DIFY_AGENT_SERVER_SECRET_KEY", _base64url_secret(secrets.token_bytes(32)))
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.agent_stub_api_base_url == "https://agent.example.com/agent-stub"
|
||||
|
||||
|
||||
def test_server_settings_ignores_obsolete_legacy_settings_namespace(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -63,45 +72,53 @@ def test_server_settings_ignores_obsolete_legacy_settings_namespace(monkeypatch:
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.agent_stub_url is None
|
||||
assert settings.agent_stub_api_base_url is None
|
||||
|
||||
|
||||
def test_server_settings_rejects_agent_stub_url_with_query_or_fragment() -> None:
|
||||
def test_server_settings_rejects_agent_stub_api_base_url_with_query_or_fragment() -> None:
|
||||
secret = _base64url_secret(secrets.token_bytes(32))
|
||||
|
||||
with pytest.raises(ValidationError, match="query string or fragment"):
|
||||
_ = ServerSettings(
|
||||
agent_stub_url="https://agent.example.com/agent-stub?x=1",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub?x=1",
|
||||
server_secret_key=secret,
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError, match="query string or fragment"):
|
||||
_ = ServerSettings(
|
||||
agent_stub_url="https://agent.example.com/agent-stub#fragment",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub#fragment",
|
||||
server_secret_key=secret,
|
||||
)
|
||||
|
||||
|
||||
def test_server_settings_rejects_public_agent_stub_url_without_secret_key() -> None:
|
||||
def test_server_settings_rejects_agent_stub_api_base_url_with_unexpected_path() -> None:
|
||||
with pytest.raises(ValidationError, match="empty or /agent-stub"):
|
||||
_ = ServerSettings(
|
||||
agent_stub_api_base_url="https://agent.example.com/foo",
|
||||
server_secret_key=_base64url_secret(secrets.token_bytes(32)),
|
||||
)
|
||||
|
||||
|
||||
def test_server_settings_rejects_public_agent_stub_api_base_url_without_secret_key() -> None:
|
||||
with pytest.raises(ValidationError, match="DIFY_AGENT_SERVER_SECRET_KEY"):
|
||||
_ = ServerSettings(agent_stub_url="https://agent.example.com/agent-stub")
|
||||
_ = ServerSettings(agent_stub_api_base_url="https://agent.example.com/agent-stub")
|
||||
|
||||
|
||||
def test_server_settings_accepts_grpc_agent_stub_url_and_bind_override() -> None:
|
||||
def test_server_settings_accepts_grpc_agent_stub_api_base_url_and_bind_override() -> None:
|
||||
settings = ServerSettings(
|
||||
agent_stub_url="grpc://agent.example.com:9091",
|
||||
agent_stub_api_base_url="grpc://agent.example.com:9091",
|
||||
agent_stub_grpc_bind_address="0.0.0.0:9191",
|
||||
server_secret_key=_base64url_secret(secrets.token_bytes(32)),
|
||||
)
|
||||
|
||||
assert settings.agent_stub_url == "grpc://agent.example.com:9091"
|
||||
assert settings.agent_stub_api_base_url == "grpc://agent.example.com:9091"
|
||||
assert settings.agent_stub_grpc_bind_address == "0.0.0.0:9191"
|
||||
|
||||
|
||||
def test_server_settings_rejects_grpc_bind_override_without_grpc_url() -> None:
|
||||
with pytest.raises(ValidationError, match="grpc://"):
|
||||
_ = ServerSettings(
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_grpc_bind_address="0.0.0.0:9191",
|
||||
server_secret_key=_base64url_secret(secrets.token_bytes(32)),
|
||||
)
|
||||
@ -122,36 +139,33 @@ def test_server_settings_rejects_padded_or_quoted_server_secret_key() -> None:
|
||||
_ = ServerSettings(server_secret_key=f'"{secret}"')
|
||||
|
||||
|
||||
def test_server_settings_normalizes_dify_api_base_url_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_DIFY_API_BASE_URL", "https://api.example.com/")
|
||||
monkeypatch.setenv("DIFY_AGENT_DIFY_API_INNER_API_KEY", "inner-secret")
|
||||
def test_server_settings_normalizes_inner_api_url_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_INNER_API_URL", "https://api.example.com/")
|
||||
monkeypatch.setenv("DIFY_AGENT_INNER_API_KEY", "inner-secret")
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.dify_api_base_url == "https://api.example.com"
|
||||
assert settings.dify_api_inner_api_key == "inner-secret"
|
||||
assert settings.inner_api_url == "https://api.example.com"
|
||||
assert settings.inner_api_key == "inner-secret"
|
||||
|
||||
|
||||
def test_server_settings_requires_inner_api_key_when_dify_api_base_url_is_set() -> None:
|
||||
with pytest.raises(ValidationError, match="DIFY_AGENT_DIFY_API_INNER_API_KEY"):
|
||||
_ = ServerSettings(dify_api_base_url="https://api.example.com")
|
||||
|
||||
settings = ServerSettings(dify_api_inner_api_key="inner-secret")
|
||||
assert settings.dify_api_inner_api_key == "inner-secret"
|
||||
assert settings.dify_api_base_url is None
|
||||
def test_server_settings_allows_inner_api_url_without_key_until_a_bridge_is_used() -> None:
|
||||
settings = ServerSettings(inner_api_key="inner-secret")
|
||||
assert settings.inner_api_key == "inner-secret"
|
||||
assert settings.inner_api_url == "http://localhost:5001"
|
||||
|
||||
|
||||
def test_server_settings_rejects_dify_api_base_url_with_query_or_fragment() -> None:
|
||||
def test_server_settings_rejects_inner_api_url_with_query_or_fragment() -> None:
|
||||
with pytest.raises(ValidationError, match="query string or fragment"):
|
||||
_ = ServerSettings(
|
||||
dify_api_base_url="https://api.example.com?x=1",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com?x=1",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError, match="query string or fragment"):
|
||||
_ = ServerSettings(
|
||||
dify_api_base_url="https://api.example.com#frag",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com#frag",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
|
||||
@ -173,15 +187,15 @@ def test_server_settings_create_agent_stub_file_request_handler_returns_none_wit
|
||||
|
||||
def test_server_settings_create_agent_stub_file_request_handler_returns_handler_when_configured() -> None:
|
||||
settings = ServerSettings(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
handler = settings.create_agent_stub_file_request_handler()
|
||||
|
||||
assert isinstance(handler, DifyApiAgentStubFileRequestHandler)
|
||||
assert handler.dify_api_base_url == "https://api.example.com"
|
||||
assert handler.dify_api_inner_api_key == "inner-secret"
|
||||
assert handler.inner_api_url == "https://api.example.com"
|
||||
assert handler.inner_api_key == "inner-secret"
|
||||
|
||||
|
||||
def test_server_settings_create_agent_stub_drive_request_handler_returns_none_without_full_settings() -> None:
|
||||
@ -190,8 +204,8 @@ def test_server_settings_create_agent_stub_drive_request_handler_returns_none_wi
|
||||
|
||||
def test_server_settings_create_agent_stub_drive_request_handler_returns_handler_when_configured() -> None:
|
||||
settings = ServerSettings(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
outbound_http_connect_timeout=11,
|
||||
outbound_http_read_timeout=22,
|
||||
outbound_http_write_timeout=33,
|
||||
@ -201,8 +215,8 @@ def test_server_settings_create_agent_stub_drive_request_handler_returns_handler
|
||||
handler = settings.create_agent_stub_drive_request_handler()
|
||||
|
||||
assert isinstance(handler, DifyApiAgentStubDriveRequestHandler)
|
||||
assert handler.dify_api_base_url == "https://api.example.com"
|
||||
assert handler.dify_api_inner_api_key == "inner-secret"
|
||||
assert handler.inner_api_url == "https://api.example.com"
|
||||
assert handler.inner_api_key == "inner-secret"
|
||||
timeout = cast(httpx.Timeout, handler.timeout)
|
||||
assert timeout.connect == 11
|
||||
assert timeout.read == 22
|
||||
|
||||
@ -111,7 +111,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
|
||||
],
|
||||
assertions=[
|
||||
"assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')",
|
||||
"assert dify_agent_layers_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', 'DifyDriveFileConfig', 'DifyDriveLayerConfig', 'DifyDriveSkillConfig']",
|
||||
"assert dify_agent_layers_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', 'DifyDriveLayerConfig', 'DifyDriveSkillConfig']",
|
||||
"assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']",
|
||||
"assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']",
|
||||
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']",
|
||||
@ -159,6 +159,14 @@ def test_agent_stub_client_and_protocol_imports_are_client_safe() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_server_settings_import_does_not_import_agent_stub_app() -> None:
|
||||
_run_import_check(
|
||||
blocked_imports=["dify_agent.agent_stub.server.app"],
|
||||
imports=["dify_agent.server.settings"],
|
||||
assertions=["assert hasattr(dify_agent_server_settings, 'ServerSettings')"],
|
||||
)
|
||||
|
||||
|
||||
def test_agenton_collection_roots_do_not_eagerly_import_pydantic_ai_implementations() -> None:
|
||||
_run_import_check(
|
||||
blocked_imports=[
|
||||
|
||||
@ -22,7 +22,7 @@ SERVER_RUNTIME_DEPENDENCIES = {
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"redis>=7.4.0,<8.0.0",
|
||||
"shell-session-manager==2.2.0",
|
||||
"shell-session-manager==2.2.1",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
}
|
||||
|
||||
@ -65,11 +65,24 @@ def test_default_package_discovery_excludes_example_packages() -> None:
|
||||
assert "dify_agent_examples*" not in find_config["include"]
|
||||
|
||||
|
||||
def test_project_declares_console_script_and_shellctl_docker_version() -> None:
|
||||
def test_project_declares_console_script_and_local_sandbox_docker_version() -> None:
|
||||
pyproject = _read_pyproject()
|
||||
scripts = pyproject["project"]["scripts"]
|
||||
dockerfile = (PROJECT_ROOT / "docker" / "shellctl" / "Dockerfile").read_text(encoding="utf-8")
|
||||
dockerfile = (PROJECT_ROOT / "docker" / "local-sandbox" / "Dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
assert scripts["dify-agent"] == "dify_agent.agent_stub.cli.main:main"
|
||||
assert scripts["dify-agent-stub-server"] == "dify_agent.agent_stub.server.cli:main"
|
||||
assert "shell-session-manager==2.2.0" in dockerfile
|
||||
assert "SHELL_SESSION_MANAGER_VERSION=2.2.1" in dockerfile
|
||||
|
||||
|
||||
def test_local_sandbox_dockerfile_installs_stub_client_and_shellctl() -> None:
|
||||
dockerfile = (PROJECT_ROOT / "docker" / "local-sandbox" / "Dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
assert "uv sync --frozen --no-dev --no-editable --extra grpc" in dockerfile
|
||||
assert "SHELL_SESSION_MANAGER_VERSION=2.2.1" in dockerfile
|
||||
assert "shell-session-manager==${SHELL_SESSION_MANAGER_VERSION}" in dockerfile
|
||||
assert "DIFY_AGENT_STUB_DRIVE_BASE=/mnt/drive" in dockerfile
|
||||
assert "ln -s ${VIRTUAL_ENV}/bin/dify-agent /usr/local/bin/dify-agent" in dockerfile
|
||||
assert "ln -s ${VIRTUAL_ENV}/bin/shellctl /usr/local/bin/shellctl" in dockerfile
|
||||
assert "mkdir -p /mnt/drive" in dockerfile
|
||||
assert '["shellctl", "serve", "--listen", "0.0.0.0:5004"]' in dockerfile
|
||||
|
||||
8
dify-agent/uv.lock
generated
8
dify-agent/uv.lock
generated
@ -639,7 +639,7 @@ requires-dist = [
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.2.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.2.1" },
|
||||
{ name = "typer", specifier = ">=0.16.1,<0.17" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
|
||||
@ -3286,7 +3286,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "shell-session-manager"
|
||||
version = "2.2.0"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@ -3298,9 +3298,9 @@ dependencies = [
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/84/aa6a86e7686b0c1e67b17ce4f5db6a42f115f1269d1f85362e22416b5829/shell_session_manager-2.2.0.tar.gz", hash = "sha256:ed31f12eecd30ad342dab9713651e2cb259b9beea6a6043842b73616c21b3070", size = 49479, upload-time = "2026-06-02T12:49:34.988Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/c3/83701914c5194e0390b93a05685ddfceda425348523254f43fdcb5024a37/shell_session_manager-2.2.1.tar.gz", hash = "sha256:421531c8bca5a586e9245282e13fdbe2566fca34e72a7320749c745cc2a935ee", size = 51380, upload-time = "2026-06-19T12:21:58.67Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/cc/71fa09d0d865ee652312067d9d13c51b7800cdc0e54afe0e076ad1a29520/shell_session_manager-2.2.0-py3-none-any.whl", hash = "sha256:338cca9716facec60cc3985c1d88837c2f23abbc17ff2b61e58b4e4a0f9f19ad", size = 47240, upload-time = "2026-06-02T12:49:33.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/b6/b8d84ff7e59661cef85a1f021f500c798451121775f5aafd7b670ba63117/shell_session_manager-2.2.1-py3-none-any.whl", hash = "sha256:b7452dd5d50b5f55d2a108a2c422ed9883a817e2145f884844d072ec95dcaff8", size = 48895, upload-time = "2026-06-19T12:21:57.183Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -227,7 +227,6 @@ export type MessageFeedbackPayload = {
|
||||
}
|
||||
|
||||
export type AgentDriveDeleteResponse = {
|
||||
config_version_id?: string | null
|
||||
removed_keys?: Array<string>
|
||||
result: string
|
||||
}
|
||||
@ -237,7 +236,6 @@ export type AgentDriveFilePayload = {
|
||||
}
|
||||
|
||||
export type AgentDriveFileCommitResponse = {
|
||||
config_version_id?: string | null
|
||||
file: AgentDriveFileResponse
|
||||
}
|
||||
|
||||
@ -321,7 +319,7 @@ export type SandboxUploadResponse = {
|
||||
|
||||
export type AgentSkillUploadResponse = {
|
||||
manifest: SkillManifest
|
||||
skill: AgentSkillRefConfig
|
||||
skill: AgentUploadedSkillResponse
|
||||
}
|
||||
|
||||
export type SkillToolInferenceResult = {
|
||||
@ -517,7 +515,6 @@ export type AgentSoulConfig = {
|
||||
prompt?: AgentSoulPromptConfig
|
||||
sandbox?: AgentSoulSandboxConfig
|
||||
schema_version?: number
|
||||
skills_files?: AgentSoulSkillsFilesConfig
|
||||
tools?: AgentSoulToolsConfig
|
||||
}
|
||||
|
||||
@ -567,14 +564,6 @@ export type AgentComposerSoulCandidatesResponse = {
|
||||
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
|
||||
human_contacts?: Array<AgentHumanContactConfig>
|
||||
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
|
||||
skills_files?: Array<
|
||||
| ({
|
||||
kind: 'skill'
|
||||
} & AgentComposerSkillCandidateResponse)
|
||||
| ({
|
||||
kind: 'file'
|
||||
} & AgentComposerFileCandidateResponse)
|
||||
>
|
||||
}
|
||||
|
||||
export type ComposerCandidateCapabilities = {
|
||||
@ -813,18 +802,12 @@ export type SkillManifest = {
|
||||
size: number
|
||||
}
|
||||
|
||||
export type AgentSkillRefConfig = {
|
||||
description?: string | null
|
||||
file_id?: string | null
|
||||
full_archive_file_id?: string | null
|
||||
full_archive_key?: string | null
|
||||
id?: string | null
|
||||
manifest_files?: Array<string> | null
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
skill_md_file_id?: string | null
|
||||
skill_md_key?: string | null
|
||||
[key: string]: unknown
|
||||
export type AgentUploadedSkillResponse = {
|
||||
archive_key?: string | null
|
||||
description: string
|
||||
name: string
|
||||
path: string
|
||||
skill_md_key: string
|
||||
}
|
||||
|
||||
export type CliToolSuggestion = {
|
||||
@ -971,11 +954,6 @@ export type AgentSoulSandboxConfig = {
|
||||
provider?: string | null
|
||||
}
|
||||
|
||||
export type AgentSoulSkillsFilesConfig = {
|
||||
files?: Array<AgentFileRefConfig>
|
||||
skills?: Array<AgentSkillRefConfig>
|
||||
}
|
||||
|
||||
export type AgentSoulToolsConfig = {
|
||||
cli_tools?: Array<AgentCliToolConfig>
|
||||
dify_tools?: Array<AgentSoulDifyToolConfig>
|
||||
@ -1098,37 +1076,6 @@ export type AgentKnowledgeDatasetConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentComposerSkillCandidateResponse = {
|
||||
description?: string | null
|
||||
file_id?: string | null
|
||||
full_archive_file_id?: string | null
|
||||
full_archive_key?: string | null
|
||||
id?: string | null
|
||||
kind?: 'skill'
|
||||
manifest_files?: Array<string> | null
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
skill_md_file_id?: string | null
|
||||
skill_md_key?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentComposerFileCandidateResponse = {
|
||||
drive_key?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
kind?: 'file'
|
||||
name?: string | null
|
||||
reference?: string | null
|
||||
remote_url?: string | null
|
||||
tenant_id?: string | null
|
||||
transfer_method?: string | null
|
||||
type?: string | null
|
||||
upload_file_id?: string | null
|
||||
url?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentModerationProviderConfig = {
|
||||
api_based_extension_id?: string | null
|
||||
inputs_config?: AgentModerationIoConfig | null
|
||||
@ -1323,21 +1270,6 @@ export type AgentSandboxProviderConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentFileRefConfig = {
|
||||
drive_key?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
reference?: string | null
|
||||
remote_url?: string | null
|
||||
tenant_id?: string | null
|
||||
transfer_method?: string | null
|
||||
type?: string | null
|
||||
upload_file_id?: string | null
|
||||
url?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentSoulDifyToolConfig = {
|
||||
credential_ref?: AgentSoulDifyToolCredentialRef | null
|
||||
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
|
||||
@ -1406,6 +1338,21 @@ export type DeclaredOutputFileConfig = {
|
||||
mime_types?: Array<string>
|
||||
}
|
||||
|
||||
export type AgentFileRefConfig = {
|
||||
drive_key?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
reference?: string | null
|
||||
remote_url?: string | null
|
||||
tenant_id?: string | null
|
||||
transfer_method?: string | null
|
||||
type?: string | null
|
||||
upload_file_id?: string | null
|
||||
url?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentCliToolAuthorizationStatus
|
||||
= | 'allowed'
|
||||
| 'authorized'
|
||||
|
||||
@ -99,7 +99,6 @@ export const zMessageFeedbackPayload = z.object({
|
||||
* AgentDriveDeleteResponse
|
||||
*/
|
||||
export const zAgentDriveDeleteResponse = z.object({
|
||||
config_version_id: z.string().nullish(),
|
||||
removed_keys: z.array(z.string()).optional(),
|
||||
result: z.string(),
|
||||
})
|
||||
@ -457,7 +456,6 @@ export const zAgentDriveFileResponse = z.object({
|
||||
* AgentDriveFileCommitResponse
|
||||
*/
|
||||
export const zAgentDriveFileCommitResponse = z.object({
|
||||
config_version_id: z.string().nullish(),
|
||||
file: zAgentDriveFileResponse,
|
||||
})
|
||||
|
||||
@ -663,19 +661,14 @@ export const zSkillManifest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillRefConfig
|
||||
* AgentUploadedSkillResponse
|
||||
*/
|
||||
export const zAgentSkillRefConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
full_archive_file_id: z.string().max(255).nullish(),
|
||||
full_archive_key: z.string().max(512).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
manifest_files: z.array(z.string()).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
path: z.string().nullish(),
|
||||
skill_md_file_id: z.string().max(255).nullish(),
|
||||
skill_md_key: z.string().max(512).nullish(),
|
||||
export const zAgentUploadedSkillResponse = z.object({
|
||||
archive_key: z.string().nullish(),
|
||||
description: z.string(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
skill_md_key: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -683,7 +676,7 @@ export const zAgentSkillRefConfig = z.object({
|
||||
*/
|
||||
export const zAgentSkillUploadResponse = z.object({
|
||||
manifest: zSkillManifest,
|
||||
skill: zAgentSkillRefConfig,
|
||||
skill: zAgentUploadedSkillResponse,
|
||||
})
|
||||
|
||||
/**
|
||||
@ -1038,41 +1031,6 @@ export const zAgentKnowledgeDatasetConfig = z.object({
|
||||
name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerSkillCandidateResponse
|
||||
*/
|
||||
export const zAgentComposerSkillCandidateResponse = z.object({
|
||||
description: z.string().nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
full_archive_file_id: z.string().max(255).nullish(),
|
||||
full_archive_key: z.string().max(512).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
kind: z.literal('skill').optional().default('skill'),
|
||||
manifest_files: z.array(z.string()).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
path: z.string().nullish(),
|
||||
skill_md_file_id: z.string().max(255).nullish(),
|
||||
skill_md_key: z.string().max(512).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerFileCandidateResponse
|
||||
*/
|
||||
export const zAgentComposerFileCandidateResponse = z.object({
|
||||
drive_key: z.string().max(512).nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
kind: z.literal('file').optional().default('file'),
|
||||
name: z.string().max(255).nullish(),
|
||||
reference: z.string().max(255).nullish(),
|
||||
remote_url: z.string().nullish(),
|
||||
tenant_id: z.string().max(255).nullish(),
|
||||
transfer_method: z.string().max(64).nullish(),
|
||||
type: z.string().max(64).nullish(),
|
||||
upload_file_id: z.string().max(255).nullish(),
|
||||
url: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleAccount
|
||||
*/
|
||||
@ -1393,39 +1351,6 @@ export const zAgentSoulSandboxConfig = z.object({
|
||||
provider: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentFileRefConfig
|
||||
*/
|
||||
export const zAgentFileRefConfig = z.object({
|
||||
drive_key: z.string().max(512).nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
reference: z.string().max(255).nullish(),
|
||||
remote_url: z.string().nullish(),
|
||||
tenant_id: z.string().max(255).nullish(),
|
||||
transfer_method: z.string().max(64).nullish(),
|
||||
type: z.string().max(64).nullish(),
|
||||
upload_file_id: z.string().max(255).nullish(),
|
||||
url: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulSkillsFilesConfig
|
||||
*/
|
||||
export const zAgentSoulSkillsFilesConfig = z.object({
|
||||
files: z.array(zAgentFileRefConfig).optional(),
|
||||
skills: z.array(zAgentSkillRefConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowNodeJobMetadata
|
||||
*/
|
||||
export const zWorkflowNodeJobMetadata = z.object({
|
||||
agent_soul: z.record(z.string(), z.unknown()).nullish(),
|
||||
file_refs: z.array(zAgentFileRefConfig).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* DeclaredArrayItem
|
||||
*
|
||||
@ -1469,6 +1394,31 @@ export const zDeclaredOutputFileConfig = z.object({
|
||||
mime_types: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentFileRefConfig
|
||||
*/
|
||||
export const zAgentFileRefConfig = z.object({
|
||||
drive_key: z.string().max(512).nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
reference: z.string().max(255).nullish(),
|
||||
remote_url: z.string().nullish(),
|
||||
tenant_id: z.string().max(255).nullish(),
|
||||
transfer_method: z.string().max(64).nullish(),
|
||||
type: z.string().max(64).nullish(),
|
||||
upload_file_id: z.string().max(255).nullish(),
|
||||
url: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowNodeJobMetadata
|
||||
*/
|
||||
export const zWorkflowNodeJobMetadata = z.object({
|
||||
agent_soul: z.record(z.string(), z.unknown()).nullish(),
|
||||
file_refs: z.array(zAgentFileRefConfig).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolAuthorizationStatus
|
||||
*
|
||||
@ -1579,22 +1529,6 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
|
||||
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
|
||||
human_contacts: z.array(zAgentHumanContactConfig).optional(),
|
||||
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
|
||||
skills_files: z
|
||||
.array(
|
||||
z.union([
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('skill'),
|
||||
})
|
||||
.and(zAgentComposerSkillCandidateResponse),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('file'),
|
||||
})
|
||||
.and(zAgentComposerFileCandidateResponse),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -1814,7 +1748,6 @@ export const zAgentSoulConfig = z.object({
|
||||
prompt: zAgentSoulPromptConfig.optional(),
|
||||
sandbox: zAgentSoulSandboxConfig.optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
skills_files: zAgentSoulSkillsFilesConfig.optional(),
|
||||
tools: zAgentSoulToolsConfig.optional(),
|
||||
})
|
||||
|
||||
|
||||
@ -926,11 +926,11 @@ export const drive = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)
|
||||
* Delete one drive file by key via drive commit-null semantics
|
||||
*/
|
||||
export const delete_ = oc
|
||||
.route({
|
||||
description: 'Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)',
|
||||
description: 'Delete one drive file by key via drive commit-null semantics',
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'deleteAppsByAppIdAgentFiles',
|
||||
@ -1056,12 +1056,11 @@ export const inferTools = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)
|
||||
* Delete a standardized skill by removing its known drive keys via commit-null
|
||||
*/
|
||||
export const delete2 = oc
|
||||
.route({
|
||||
description:
|
||||
'Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)',
|
||||
description: 'Delete a standardized skill by removing its known drive keys via commit-null',
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'deleteAppsByAppIdAgentSkillsBySlug',
|
||||
|
||||
@ -217,7 +217,6 @@ export type AgentDriveSkillInspectResponse = {
|
||||
}
|
||||
|
||||
export type AgentDriveDeleteResponse = {
|
||||
config_version_id?: string | null
|
||||
removed_keys?: Array<string>
|
||||
result: string
|
||||
}
|
||||
@ -227,7 +226,6 @@ export type AgentDriveFilePayload = {
|
||||
}
|
||||
|
||||
export type AgentDriveFileCommitResponse = {
|
||||
config_version_id?: string | null
|
||||
file: AgentDriveFileResponse
|
||||
}
|
||||
|
||||
@ -239,7 +237,7 @@ export type AgentLogResponse = {
|
||||
|
||||
export type AgentSkillUploadResponse = {
|
||||
manifest: SkillManifest
|
||||
skill: AgentSkillRefConfig
|
||||
skill: AgentUploadedSkillResponse
|
||||
}
|
||||
|
||||
export type SkillToolInferenceResult = {
|
||||
@ -1370,18 +1368,12 @@ export type SkillManifest = {
|
||||
size: number
|
||||
}
|
||||
|
||||
export type AgentSkillRefConfig = {
|
||||
description?: string | null
|
||||
file_id?: string | null
|
||||
full_archive_file_id?: string | null
|
||||
full_archive_key?: string | null
|
||||
id?: string | null
|
||||
manifest_files?: Array<string> | null
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
skill_md_file_id?: string | null
|
||||
skill_md_key?: string | null
|
||||
[key: string]: unknown
|
||||
export type AgentUploadedSkillResponse = {
|
||||
archive_key?: string | null
|
||||
description: string
|
||||
name: string
|
||||
path: string
|
||||
skill_md_key: string
|
||||
}
|
||||
|
||||
export type CliToolSuggestion = {
|
||||
@ -1803,7 +1795,6 @@ export type AgentSoulConfig = {
|
||||
prompt?: AgentSoulPromptConfig
|
||||
sandbox?: AgentSoulSandboxConfig
|
||||
schema_version?: number
|
||||
skills_files?: AgentSoulSkillsFilesConfig
|
||||
tools?: AgentSoulToolsConfig
|
||||
}
|
||||
|
||||
@ -1900,14 +1891,6 @@ export type AgentComposerSoulCandidatesResponse = {
|
||||
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
|
||||
human_contacts?: Array<AgentHumanContactConfig>
|
||||
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
|
||||
skills_files?: Array<
|
||||
| ({
|
||||
kind: 'skill'
|
||||
} & AgentComposerSkillCandidateResponse)
|
||||
| ({
|
||||
kind: 'file'
|
||||
} & AgentComposerFileCandidateResponse)
|
||||
>
|
||||
}
|
||||
|
||||
export type ComposerCandidateCapabilities = {
|
||||
@ -2169,11 +2152,6 @@ export type AgentSoulSandboxConfig = {
|
||||
provider?: string | null
|
||||
}
|
||||
|
||||
export type AgentSoulSkillsFilesConfig = {
|
||||
files?: Array<AgentFileRefConfig>
|
||||
skills?: Array<AgentSkillRefConfig>
|
||||
}
|
||||
|
||||
export type AgentSoulToolsConfig = {
|
||||
cli_tools?: Array<AgentCliToolConfig>
|
||||
dify_tools?: Array<AgentSoulDifyToolConfig>
|
||||
@ -2307,37 +2285,6 @@ export type AgentKnowledgeDatasetConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentComposerSkillCandidateResponse = {
|
||||
description?: string | null
|
||||
file_id?: string | null
|
||||
full_archive_file_id?: string | null
|
||||
full_archive_key?: string | null
|
||||
id?: string | null
|
||||
kind?: 'skill'
|
||||
manifest_files?: Array<string> | null
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
skill_md_file_id?: string | null
|
||||
skill_md_key?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentComposerFileCandidateResponse = {
|
||||
drive_key?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
kind?: 'file'
|
||||
name?: string | null
|
||||
reference?: string | null
|
||||
remote_url?: string | null
|
||||
tenant_id?: string | null
|
||||
transfer_method?: string | null
|
||||
type?: string | null
|
||||
upload_file_id?: string | null
|
||||
url?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type CheckResultView = {
|
||||
passed: boolean
|
||||
reason?: string | null
|
||||
@ -2488,21 +2435,6 @@ export type AgentSandboxProviderConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentFileRefConfig = {
|
||||
drive_key?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
reference?: string | null
|
||||
remote_url?: string | null
|
||||
tenant_id?: string | null
|
||||
transfer_method?: string | null
|
||||
type?: string | null
|
||||
upload_file_id?: string | null
|
||||
url?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentSoulDifyToolConfig = {
|
||||
credential_ref?: AgentSoulDifyToolCredentialRef | null
|
||||
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
|
||||
@ -2528,6 +2460,21 @@ export type AgentSoulDifyToolConfig = {
|
||||
tool_name?: string | null
|
||||
}
|
||||
|
||||
export type AgentFileRefConfig = {
|
||||
drive_key?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
reference?: string | null
|
||||
remote_url?: string | null
|
||||
tenant_id?: string | null
|
||||
transfer_method?: string | null
|
||||
type?: string | null
|
||||
upload_file_id?: string | null
|
||||
url?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop'
|
||||
|
||||
export type DeclaredOutputRetryConfig = {
|
||||
|
||||
@ -120,7 +120,6 @@ export const zAgentDrivePreviewResponse = z.object({
|
||||
* AgentDriveDeleteResponse
|
||||
*/
|
||||
export const zAgentDriveDeleteResponse = z.object({
|
||||
config_version_id: z.string().nullish(),
|
||||
removed_keys: z.array(z.string()).optional(),
|
||||
result: z.string(),
|
||||
})
|
||||
@ -1011,7 +1010,6 @@ export const zAgentDriveFileResponse = z.object({
|
||||
* AgentDriveFileCommitResponse
|
||||
*/
|
||||
export const zAgentDriveFileCommitResponse = z.object({
|
||||
config_version_id: z.string().nullish(),
|
||||
file: zAgentDriveFileResponse,
|
||||
})
|
||||
|
||||
@ -1043,19 +1041,14 @@ export const zSkillManifest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillRefConfig
|
||||
* AgentUploadedSkillResponse
|
||||
*/
|
||||
export const zAgentSkillRefConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
full_archive_file_id: z.string().max(255).nullish(),
|
||||
full_archive_key: z.string().max(512).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
manifest_files: z.array(z.string()).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
path: z.string().nullish(),
|
||||
skill_md_file_id: z.string().max(255).nullish(),
|
||||
skill_md_key: z.string().max(512).nullish(),
|
||||
export const zAgentUploadedSkillResponse = z.object({
|
||||
archive_key: z.string().nullish(),
|
||||
description: z.string(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
skill_md_key: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -1063,7 +1056,7 @@ export const zAgentSkillRefConfig = z.object({
|
||||
*/
|
||||
export const zAgentSkillUploadResponse = z.object({
|
||||
manifest: zSkillManifest,
|
||||
skill: zAgentSkillRefConfig,
|
||||
skill: zAgentUploadedSkillResponse,
|
||||
})
|
||||
|
||||
/**
|
||||
@ -2645,41 +2638,6 @@ export const zAgentKnowledgeDatasetConfig = z.object({
|
||||
name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerSkillCandidateResponse
|
||||
*/
|
||||
export const zAgentComposerSkillCandidateResponse = z.object({
|
||||
description: z.string().nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
full_archive_file_id: z.string().max(255).nullish(),
|
||||
full_archive_key: z.string().max(512).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
kind: z.literal('skill').optional().default('skill'),
|
||||
manifest_files: z.array(z.string()).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
path: z.string().nullish(),
|
||||
skill_md_file_id: z.string().max(255).nullish(),
|
||||
skill_md_key: z.string().max(512).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerFileCandidateResponse
|
||||
*/
|
||||
export const zAgentComposerFileCandidateResponse = z.object({
|
||||
drive_key: z.string().max(512).nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
kind: z.literal('file').optional().default('file'),
|
||||
name: z.string().max(255).nullish(),
|
||||
reference: z.string().max(255).nullish(),
|
||||
remote_url: z.string().nullish(),
|
||||
tenant_id: z.string().max(255).nullish(),
|
||||
transfer_method: z.string().max(64).nullish(),
|
||||
type: z.string().max(64).nullish(),
|
||||
upload_file_id: z.string().max(255).nullish(),
|
||||
url: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* CheckResultView
|
||||
*
|
||||
@ -2898,14 +2856,6 @@ export const zAgentFileRefConfig = z.object({
|
||||
url: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulSkillsFilesConfig
|
||||
*/
|
||||
export const zAgentSoulSkillsFilesConfig = z.object({
|
||||
files: z.array(zAgentFileRefConfig).optional(),
|
||||
skills: z.array(zAgentSkillRefConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowNodeJobMetadata
|
||||
*/
|
||||
@ -3060,22 +3010,6 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
|
||||
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
|
||||
human_contacts: z.array(zAgentHumanContactConfig).optional(),
|
||||
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
|
||||
skills_files: z
|
||||
.array(
|
||||
z.union([
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('skill'),
|
||||
})
|
||||
.and(zAgentComposerSkillCandidateResponse),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('file'),
|
||||
})
|
||||
.and(zAgentComposerFileCandidateResponse),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -3372,7 +3306,6 @@ export const zAgentSoulConfig = z.object({
|
||||
prompt: zAgentSoulPromptConfig.optional(),
|
||||
sandbox: zAgentSoulSandboxConfig.optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
skills_files: zAgentSoulSkillsFilesConfig.optional(),
|
||||
tools: zAgentSoulToolsConfig.optional(),
|
||||
})
|
||||
|
||||
|
||||
@ -171,7 +171,7 @@ describe('Filter', () => {
|
||||
|
||||
const statusTrigger = screen.getByRole('combobox', { name: 'Success' })
|
||||
const statusChip = statusTrigger.parentElement!
|
||||
const clearButton = within(statusChip).getByRole('button', { name: 'common.operation.clear' })
|
||||
const clearButton = within(statusChip).getByRole('button', { name: /common\.operation\.clear Success/ })
|
||||
|
||||
await user.click(clearButton)
|
||||
|
||||
@ -286,7 +286,7 @@ describe('Filter', () => {
|
||||
|
||||
const periodTrigger = screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' })
|
||||
const periodChip = periodTrigger.parentElement!
|
||||
const clearButton = within(periodChip).getByRole('button', { name: 'common.operation.clear' })
|
||||
const clearButton = within(periodChip).getByRole('button', { name: /common\.operation\.clear appLog\.filter\.period\.last7days/ })
|
||||
|
||||
await user.click(clearButton)
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
|
||||
@ -64,10 +64,12 @@ export const useChat = (
|
||||
stopChat?: (taskId: string) => void,
|
||||
clearChatList?: boolean,
|
||||
clearChatListCallback?: (state: boolean) => void,
|
||||
initialConversationId?: string,
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const conversationIdRef = useRef('')
|
||||
const conversationIdRef = useRef(initialConversationId ?? '')
|
||||
const initialConversationIdRef = useRef(initialConversationId ?? '')
|
||||
const hasStopRespondedRef = useRef(false)
|
||||
const [isResponding, setIsResponding] = useState(false)
|
||||
const isRespondingRef = useRef(false)
|
||||
@ -145,6 +147,12 @@ export const useChat = (
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
initialConversationIdRef.current = initialConversationId ?? ''
|
||||
if (initialConversationId && !conversationIdRef.current)
|
||||
conversationIdRef.current = initialConversationId
|
||||
}, [initialConversationId])
|
||||
|
||||
/** Find the target node by bfs and then operate on it */
|
||||
const produceChatTreeNode = useCallback((targetId: string, operation: (node: ChatItemInTree) => void) => {
|
||||
return produce(chatTreeRef.current, (draft) => {
|
||||
@ -203,7 +211,7 @@ export const useChat = (
|
||||
}, [stopChat, handleResponding])
|
||||
|
||||
const handleRestart = useCallback((cb?: any) => {
|
||||
conversationIdRef.current = ''
|
||||
conversationIdRef.current = initialConversationIdRef.current
|
||||
taskIdRef.current = ''
|
||||
handleStop()
|
||||
setChatTree([])
|
||||
|
||||
@ -122,7 +122,9 @@ describe('Chip', () => {
|
||||
it('should show left icon by default', () => {
|
||||
const { container } = renderChip()
|
||||
|
||||
expect(container.querySelector('.i-ri-filter-3-line')).toBeInTheDocument()
|
||||
const icon = container.querySelector('.i-ri-filter-3-line')
|
||||
expect(icon).toBeInTheDocument()
|
||||
expect(icon).toHaveAttribute('aria-hidden')
|
||||
})
|
||||
|
||||
it('should hide left icon when showLeftIcon is false', () => {
|
||||
@ -140,6 +142,7 @@ describe('Chip', () => {
|
||||
renderChip({ leftIcon: <CustomIcon /> })
|
||||
|
||||
expect(screen.getByTestId('custom-icon'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('custom-icon').closest('[aria-hidden="true"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className to trigger', () => {
|
||||
@ -161,6 +164,15 @@ describe('Chip', () => {
|
||||
const panel = document.body.querySelector(`.${customPanelClass}`)
|
||||
expect(panel)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use visible focus styles on the trigger', () => {
|
||||
const { container } = renderChip()
|
||||
|
||||
expect(getTrigger(container)).toHaveClass(
|
||||
'focus-visible:ring-2',
|
||||
'focus-visible:ring-state-accent-solid',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Management', () => {
|
||||
@ -207,7 +219,15 @@ describe('Chip', () => {
|
||||
it('should call onClear when clear button is clicked', async () => {
|
||||
const { user } = renderChip({ value: 'active' })
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
|
||||
const clearButton = screen.getByRole('button', { name: /common\.operation\.clear/ })
|
||||
expect(clearButton).toHaveAccessibleName(/Active/)
|
||||
expect(clearButton).toHaveClass(
|
||||
'outline-hidden',
|
||||
'focus-visible:ring-2',
|
||||
'focus-visible:ring-state-accent-solid',
|
||||
'focus-visible:ring-inset',
|
||||
)
|
||||
expect(clearButton.querySelector('.i-ri-close-circle-fill')).toHaveAttribute('aria-hidden')
|
||||
|
||||
await user.click(clearButton)
|
||||
|
||||
@ -221,7 +241,7 @@ describe('Chip', () => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(trigger).not.toHaveAttribute('data-popup-open')
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
|
||||
const clearButton = screen.getByRole('button', { name: /common\.operation\.clear/ })
|
||||
|
||||
await user.click(clearButton)
|
||||
|
||||
@ -326,7 +346,7 @@ describe('Chip', () => {
|
||||
|
||||
// The trigger should not display any item name text
|
||||
expect(trigger?.textContent?.trim()).toBeFalsy()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.clear' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.clear/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow selecting already selected item', async () => {
|
||||
@ -368,7 +388,7 @@ describe('Chip', () => {
|
||||
renderChip({ value: 0, items: numericItems })
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Zero' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.clear/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle items with additional properties', async () => {
|
||||
|
||||
@ -45,6 +45,9 @@ function Chip<T extends ItemValue>({
|
||||
const selectedItem = items.find(item => Object.is(item.value, value))
|
||||
const triggerContent = selectedItem?.triggerName || selectedItem?.name || ''
|
||||
const hasValue = selectedItem !== undefined && value !== ''
|
||||
const clearLabel = triggerContent
|
||||
? `${t('operation.clear', { ns: 'common' })} ${triggerContent}`
|
||||
: t('operation.clear', { ns: 'common' })
|
||||
|
||||
return (
|
||||
<Select
|
||||
@ -63,14 +66,14 @@ function Chip<T extends ItemValue>({
|
||||
<SelectTrigger
|
||||
aria-label={triggerContent || t('placeholder.select', { ns: 'common' })}
|
||||
className={cn(
|
||||
'h-auto min-h-8 w-fit max-w-full cursor-pointer items-center rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt [&>*:last-child]:hidden',
|
||||
'h-auto min-h-8 w-fit max-w-full cursor-pointer items-center rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt [&>*:last-child]:hidden',
|
||||
hasValue && 'border-components-button-secondary-border! bg-components-button-secondary-bg! pr-6 shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover! data-popup-open:border-components-button-secondary-border-hover! data-popup-open:bg-components-button-secondary-bg-hover! data-popup-open:hover:border-components-button-secondary-border-hover data-popup-open:hover:bg-components-button-secondary-bg-hover!',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 grow items-center gap-1 text-left">
|
||||
{showLeftIcon && (
|
||||
<span className="p-0.5">
|
||||
<span aria-hidden="true" className="p-0.5">
|
||||
{leftIcon || (
|
||||
<span aria-hidden className={cn('i-ri-filter-3-line block size-4 text-text-tertiary', hasValue && 'text-text-secondary')} />
|
||||
)}
|
||||
@ -87,8 +90,8 @@ function Chip<T extends ItemValue>({
|
||||
{hasValue && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="group/clear absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer border-none bg-transparent p-px"
|
||||
aria-label={clearLabel}
|
||||
className="group/clear absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 cursor-pointer touch-manipulation items-center justify-center rounded-md border-none bg-transparent p-0 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset"
|
||||
onClick={onClear}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill block size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" />
|
||||
|
||||
@ -43,7 +43,7 @@ describe('Sort component — real portal integration', () => {
|
||||
|
||||
const sortButton = getSortButton()
|
||||
expect(sortButton).toBeInstanceOf(HTMLElement)
|
||||
expect(sortButton.querySelector('svg')).toBeInTheDocument()
|
||||
expect(sortButton.querySelector('.i-ri-sort-asc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens and closes the menu', async () => {
|
||||
@ -98,8 +98,8 @@ describe('Sort component — real portal integration', () => {
|
||||
if (!nameRow)
|
||||
throw new Error('Name option row not found in menu')
|
||||
|
||||
expect(statusRow.querySelector('svg')).toBeInTheDocument()
|
||||
expect(nameRow.querySelector('svg')).not.toBeInTheDocument()
|
||||
expect(statusRow.querySelector('.i-ri-check-line')).toBeInTheDocument()
|
||||
expect(nameRow.querySelector('.i-ri-check-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty selection label when value is unknown', () => {
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { RiArrowDownSLine, RiCheckLine, RiSortAsc, RiSortDesc } from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -39,7 +38,7 @@ function Sort({
|
||||
<DropdownMenu>
|
||||
<div className="relative">
|
||||
<DropdownMenuTrigger
|
||||
className="flex min-h-8 cursor-pointer items-center rounded-l-lg border-none bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt"
|
||||
className="flex min-h-8 cursor-pointer items-center rounded-l-lg border-none bg-components-input-bg-normal px-2 py-1 outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt"
|
||||
>
|
||||
<div className="flex items-center gap-0.5 px-1">
|
||||
<div className="system-sm-regular text-text-tertiary">{t('filter.sortBy', { ns: 'appLog' })}</div>
|
||||
@ -47,12 +46,12 @@ function Sort({
|
||||
{triggerContent}
|
||||
</div>
|
||||
</div>
|
||||
<RiArrowDownSLine className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line size-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="relative w-[240px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-0"
|
||||
popupClassName="relative w-[240px] rounded-xl bg-components-panel-bg-blur p-0"
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
@ -64,10 +63,10 @@ function Sort({
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
closeOnClick
|
||||
className="gap-2 rounded-lg px-2 py-[6px] pl-3"
|
||||
className="mx-0 gap-2 rounded-lg px-2 py-[6px]"
|
||||
>
|
||||
<div title={item.name} className="grow truncate system-sm-medium text-text-secondary">{item.name}</div>
|
||||
{value === item.value && <RiCheckLine className="size-4 shrink-0 text-util-colors-blue-light-blue-light-600" />}
|
||||
{value === item.value && <span aria-hidden className="i-ri-check-line size-4 shrink-0 text-util-colors-blue-light-blue-light-600" />}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
@ -77,11 +76,11 @@ function Sort({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(`filter.${order ? 'ascending' : 'descending'}`, { ns: 'appLog' })}
|
||||
className="ml-px cursor-pointer rounded-r-lg border-none bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
className="ml-px cursor-pointer rounded-r-lg border-none bg-components-button-tertiary-bg p-2 outline-hidden hover:bg-components-button-tertiary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={() => onSelect(`${order ? '' : '-'}${value}`)}
|
||||
>
|
||||
{!order && <RiSortAsc className="size-4 text-components-button-tertiary-text" aria-hidden="true" />}
|
||||
{order && <RiSortDesc className="size-4 text-components-button-tertiary-text" aria-hidden="true" />}
|
||||
{!order && <span aria-hidden className="i-ri-sort-asc size-4 text-components-button-tertiary-text" />}
|
||||
{order && <span aria-hidden className="i-ri-sort-desc size-4 text-components-button-tertiary-text" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -501,6 +501,15 @@ describe('FeaturesTrigger', () => {
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
},
|
||||
}).queryKey,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -535,6 +544,15 @@ describe('FeaturesTrigger', () => {
|
||||
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
expect(mockInvalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
},
|
||||
}).queryKey,
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass publish params to workflow publish mutation', async () => {
|
||||
|
||||
@ -29,7 +29,7 @@ import {
|
||||
// useWorkflowRunValidation,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import { hasValidRosterAgentBinding, isAgentV2NodeData } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { isAgentV2NodeData } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
@ -64,6 +64,20 @@ const FeaturesTrigger = () => {
|
||||
const lastPublishedHasUserInput = useStore(s => s.lastPublishedHasUserInput)
|
||||
|
||||
const nodes = useNodes()
|
||||
const rosterAgentIds = useMemo(() => {
|
||||
return Array.from(new Set(nodes.flatMap((node) => {
|
||||
const binding = isAgentV2NodeData(node.data) ? node.data.agent_binding : undefined
|
||||
if (
|
||||
binding?.binding_type !== 'roster_agent'
|
||||
|| typeof binding.agent_id !== 'string'
|
||||
|| binding.agent_id.length === 0
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [binding.agent_id]
|
||||
})))
|
||||
}, [nodes])
|
||||
const hasWorkflowNodes = nodes.length > 0
|
||||
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
|
||||
const endNode = nodes.find(node => node.data.type === BlockEnum.End)
|
||||
@ -169,10 +183,19 @@ const FeaturesTrigger = () => {
|
||||
updatePublishedWorkflow(appID!)
|
||||
updateAppDetail()
|
||||
invalidateAppTriggers(appID!)
|
||||
if (nodes.some(node => isAgentV2NodeData(node.data) && hasValidRosterAgentBinding(node.data))) {
|
||||
if (rosterAgentIds.length > 0) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
void Promise.all(rosterAgentIds.map(agentId => queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
},
|
||||
}).queryKey,
|
||||
})))
|
||||
}
|
||||
workflowStore.getState().setPublishedAt(res.created_at)
|
||||
workflowStore.getState().setLastPublishedHasUserInput(hasUserInputNode)
|
||||
@ -182,7 +205,7 @@ const FeaturesTrigger = () => {
|
||||
else {
|
||||
throw new Error('Checklist failed')
|
||||
}
|
||||
}, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, appID, t, updatePublishedWorkflow, updateAppDetail, invalidateAppTriggers, nodes, queryClient, workflowStore, hasUserInputNode, resetWorkflowVersionHistory])
|
||||
}, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, appID, t, updatePublishedWorkflow, updateAppDetail, invalidateAppTriggers, rosterAgentIds, queryClient, workflowStore, hasUserInputNode, resetWorkflowVersionHistory])
|
||||
|
||||
const onPublisherToggle = useCallback((state: boolean) => {
|
||||
if (state)
|
||||
|
||||
@ -356,7 +356,7 @@ describe('Blocks', () => {
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Agent' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'agentV2.roster.nodeSelector.startFromScratch' }))
|
||||
await user.click(await screen.findByRole('option', { name: 'agentV2.roster.nodeSelector.startFromScratch' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.AgentV2, {
|
||||
agent_binding: {
|
||||
|
||||
@ -5,7 +5,6 @@ import type { AgentRosterNodeData } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxItem,
|
||||
@ -32,6 +31,14 @@ import BlockIcon from '../block-icon'
|
||||
|
||||
const AGENT_SELECTOR_PAGE_SIZE = 8
|
||||
|
||||
type AgentSelectorOption
|
||||
= | AgentInviteOptionResponse
|
||||
| AgentSelectorActionOption
|
||||
|
||||
type AgentSelectorActionOption
|
||||
= | 'start-from-scratch'
|
||||
| 'manage-in-agent-console'
|
||||
|
||||
export function AgentSelectorContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
@ -60,20 +67,41 @@ export function AgentSelectorContent({
|
||||
}),
|
||||
})
|
||||
const agents = agentsQuery.data?.data ?? []
|
||||
const actionOptions: AgentSelectorActionOption[] = onStartFromScratch
|
||||
? ['start-from-scratch', 'manage-in-agent-console']
|
||||
: ['manage-in-agent-console']
|
||||
const options: AgentSelectorOption[] = [...agents, ...actionOptions]
|
||||
const getOptionLabel = (option: AgentSelectorOption) => {
|
||||
if (isAgentSelectorActionOption(option)) {
|
||||
if (option === 'start-from-scratch')
|
||||
return t('roster.nodeSelector.startFromScratch', { ns: 'agentV2' })
|
||||
|
||||
return t('roster.nodeSelector.manageInAgentConsole', { ns: 'agentV2' })
|
||||
}
|
||||
|
||||
return option.name
|
||||
}
|
||||
const handleInputValueChange = (nextSearchText: string, details: ComboboxRootChangeEventDetails) => {
|
||||
if (details.reason !== 'item-press')
|
||||
setSearchText(nextSearchText)
|
||||
}
|
||||
const handleValueChange = (agent: AgentInviteOptionResponse | null) => {
|
||||
if (!agent)
|
||||
const handleValueChange = (option: AgentSelectorOption | null) => {
|
||||
if (!option)
|
||||
return
|
||||
|
||||
if (!agent.active_config_snapshot_id) {
|
||||
if (isAgentSelectorActionOption(option)) {
|
||||
if (option === 'start-from-scratch')
|
||||
onStartFromScratch?.()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!option.active_config_snapshot_id) {
|
||||
toast.error(t('nodes.agent.modelNotSelected', { ns: 'workflow' }))
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(toAgentRosterNodeData(agent))
|
||||
onSelect(toAgentRosterNodeData(option))
|
||||
}
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen)
|
||||
@ -83,12 +111,12 @@ export function AgentSelectorContent({
|
||||
|
||||
return (
|
||||
<div className="w-60 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<Combobox<AgentInviteOptionResponse>
|
||||
<Combobox<AgentSelectorOption>
|
||||
filter={null}
|
||||
inputValue={searchText}
|
||||
items={agents}
|
||||
itemToStringLabel={getAgentLabel}
|
||||
itemToStringValue={getAgentValue}
|
||||
items={options}
|
||||
itemToStringLabel={getOptionLabel}
|
||||
itemToStringValue={getAgentSelectorOptionValue}
|
||||
open={open}
|
||||
value={null}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
@ -105,54 +133,38 @@ export function AgentSelectorContent({
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
<div className="max-h-54 overflow-y-auto p-1">
|
||||
{isLoading && (
|
||||
<AgentSelectorLoadingSkeleton label={t('loading', { ns: 'common' })} />
|
||||
)}
|
||||
{!isLoading && agentsQuery.isError && (
|
||||
<ComboboxStatus className="px-3 py-2 system-xs-regular">
|
||||
{t('roster.loadingError', { ns: 'agentV2' })}
|
||||
</ComboboxStatus>
|
||||
)}
|
||||
{!isLoading && !agentsQuery.isError && (
|
||||
<>
|
||||
<ComboboxList className="max-h-none overflow-visible p-0">
|
||||
{(agent: AgentInviteOptionResponse) => (
|
||||
<AgentSelectorItem key={agent.id} agent={agent} />
|
||||
<ComboboxList className="max-h-none overflow-visible p-0">
|
||||
<div role="presentation" className="max-h-54 overflow-y-auto p-1">
|
||||
{isLoading && (
|
||||
<AgentSelectorLoadingSkeleton label={t('loading', { ns: 'common' })} />
|
||||
)}
|
||||
{!isLoading && agentsQuery.isError && (
|
||||
<ComboboxStatus className="px-3 py-2 system-xs-regular">
|
||||
{t('roster.loadingError', { ns: 'agentV2' })}
|
||||
</ComboboxStatus>
|
||||
)}
|
||||
{!isLoading && !agentsQuery.isError && (
|
||||
<>
|
||||
{agents.length === 0 && (
|
||||
<ComboboxStatus className="px-3 py-2 system-xs-regular">
|
||||
{debouncedSearchText
|
||||
? t('roster.emptySearch', { ns: 'agentV2' })
|
||||
: t('roster.empty', { ns: 'agentV2' })}
|
||||
</ComboboxStatus>
|
||||
)}
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty className="px-3 py-2 system-xs-regular">
|
||||
{debouncedSearchText
|
||||
? t('roster.emptySearch', { ns: 'agentV2' })
|
||||
: t('roster.empty', { ns: 'agentV2' })}
|
||||
</ComboboxEmpty>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{agents.map(agent => (
|
||||
<AgentSelectorItem key={agent.id} agent={agent} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div role="presentation" className="border-t border-divider-subtle p-1">
|
||||
{actionOptions.map(option => (
|
||||
<AgentSelectorActionItem key={option} option={option} />
|
||||
))}
|
||||
</div>
|
||||
</ComboboxList>
|
||||
</Combobox>
|
||||
<div className="border-t border-divider-subtle p-1">
|
||||
{onStartFromScratch && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-7 w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={onStartFromScratch}
|
||||
>
|
||||
<span aria-hidden className="i-ri-add-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t('roster.nodeSelector.startFromScratch', { ns: 'agentV2' })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href="/roster"
|
||||
className="flex min-h-7 w-full items-center gap-2 rounded-md px-2 py-1.5 system-sm-regular text-text-secondary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-right-up-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t('roster.nodeSelector.manageInAgentConsole', { ns: 'agentV2' })}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -189,12 +201,15 @@ function AgentSelectorLoadingSkeleton({
|
||||
)
|
||||
}
|
||||
|
||||
function getAgentLabel(agent: AgentInviteOptionResponse) {
|
||||
return agent.name
|
||||
function getAgentSelectorOptionValue(option: AgentSelectorOption) {
|
||||
if (isAgentSelectorActionOption(option))
|
||||
return option
|
||||
|
||||
return option.id
|
||||
}
|
||||
|
||||
function getAgentValue(agent: AgentInviteOptionResponse) {
|
||||
return agent.id
|
||||
function isAgentSelectorActionOption(option: AgentSelectorOption): option is AgentSelectorActionOption {
|
||||
return typeof option === 'string'
|
||||
}
|
||||
|
||||
function toAgentRosterNodeData(agent: AgentInviteOptionResponse): AgentRosterNodeData {
|
||||
@ -252,6 +267,38 @@ function AgentSelectorItem({
|
||||
)
|
||||
}
|
||||
|
||||
function AgentSelectorActionItem({
|
||||
option,
|
||||
}: {
|
||||
option: AgentSelectorActionOption
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const isStartFromScratch = option === 'start-from-scratch'
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
value={option}
|
||||
render={isStartFromScratch ? undefined : <Link href="/roster" />}
|
||||
className="flex min-h-7 w-full grid-cols-none items-center gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-highlighted:bg-state-base-hover data-highlighted:text-text-secondary"
|
||||
>
|
||||
<ComboboxItemText className="flex items-center gap-2 px-0 system-sm-regular text-text-secondary">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-text-tertiary',
|
||||
isStartFromScratch ? 'i-ri-add-line' : 'i-ri-arrow-right-up-line',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{isStartFromScratch
|
||||
? t('roster.nodeSelector.startFromScratch')
|
||||
: t('roster.nodeSelector.manageInAgentConsole')}
|
||||
</span>
|
||||
</ComboboxItemText>
|
||||
</ComboboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentBlockItem({
|
||||
block,
|
||||
onSelect,
|
||||
|
||||
@ -132,6 +132,9 @@ describe('useCreateInlineAgentBinding', () => {
|
||||
body: {
|
||||
variant: 'workflow',
|
||||
save_strategy: 'node_job_only',
|
||||
binding: {
|
||||
binding_type: 'inline_agent',
|
||||
},
|
||||
soul_lock: {
|
||||
locked: false,
|
||||
},
|
||||
@ -189,6 +192,9 @@ describe('useCreateInlineAgentBinding', () => {
|
||||
body: {
|
||||
variant: 'workflow',
|
||||
save_strategy: 'node_job_only',
|
||||
binding: {
|
||||
binding_type: 'inline_agent',
|
||||
},
|
||||
soul_lock: {
|
||||
locked: false,
|
||||
},
|
||||
|
||||
@ -128,7 +128,7 @@ describe('agent/node', () => {
|
||||
expect(robotIcon?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
|
||||
})
|
||||
|
||||
it('renders inline agent name from workflow composer state', () => {
|
||||
it('renders the fixed inline setup name when workflow composer state is loaded', () => {
|
||||
render(
|
||||
<AgentV2Node
|
||||
id="agent-node"
|
||||
@ -144,7 +144,8 @@ describe('agent/node', () => {
|
||||
|
||||
expect(mockUseAgentRosterDetail).toHaveBeenCalledWith(undefined)
|
||||
expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1')
|
||||
expect(screen.getByText('Workflow Agent 1')).toHaveClass('system-xs-regular', 'text-text-secondary')
|
||||
expect(screen.queryByText('Workflow Agent 1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.name')).toHaveClass('system-xs-regular', 'text-text-secondary')
|
||||
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.type')).toHaveClass('system-2xs-regular', 'text-text-tertiary')
|
||||
})
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ const {
|
||||
mockInsertNodes,
|
||||
mockOrchestrateDrawerPanelProps,
|
||||
mockPromptEditorProps,
|
||||
mockCreateInlineAgentBinding,
|
||||
mockSetInputs,
|
||||
mockStoreState,
|
||||
mockUseAgentRosterDetail,
|
||||
@ -33,6 +34,7 @@ const {
|
||||
open: boolean
|
||||
}>,
|
||||
mockPromptEditorProps: [] as PromptEditorProps[],
|
||||
mockCreateInlineAgentBinding: vi.fn(),
|
||||
mockSetInputs: vi.fn(),
|
||||
mockStoreState: {
|
||||
openInlineAgentPanelNodeId: undefined as string | undefined,
|
||||
@ -100,6 +102,7 @@ vi.mock('../../_base/hooks/use-node-crud', () => ({
|
||||
vi.mock('@/app/components/workflow/block-selector/agent-selector', () => ({
|
||||
AgentSelectorContent: ({
|
||||
onSelect,
|
||||
onStartFromScratch,
|
||||
}: {
|
||||
onSelect: (agent: {
|
||||
description: string
|
||||
@ -110,26 +113,38 @@ vi.mock('@/app/components/workflow/block-selector/agent-selector', () => ({
|
||||
name: string
|
||||
role: string
|
||||
}) => void
|
||||
onStartFromScratch?: () => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({
|
||||
id: 'agent-2',
|
||||
name: 'Mara',
|
||||
description: 'Tender Analyst',
|
||||
icon: 'M',
|
||||
icon_background: '#D1E9FF',
|
||||
icon_type: 'emoji',
|
||||
role: 'Analyst',
|
||||
})}
|
||||
>
|
||||
Select Mara
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({
|
||||
id: 'agent-2',
|
||||
name: 'Mara',
|
||||
description: 'Tender Analyst',
|
||||
icon: 'M',
|
||||
icon_background: '#D1E9FF',
|
||||
icon_type: 'emoji',
|
||||
role: 'Analyst',
|
||||
})}
|
||||
>
|
||||
Select Mara
|
||||
</button>
|
||||
{onStartFromScratch && (
|
||||
<button type="button" onClick={onStartFromScratch}>
|
||||
Start from Scratch
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useAgentRosterDetail: (agentId?: string) => mockUseAgentRosterDetail(agentId),
|
||||
useCreateInlineAgentBinding: () => ({
|
||||
createInlineAgentBinding: mockCreateInlineAgentBinding,
|
||||
isCreatingInlineAgent: false,
|
||||
}),
|
||||
useWorkflowInlineAgentDetail: (nodeId?: string, agentId?: string | null) => mockUseWorkflowInlineAgentDetail(nodeId, agentId),
|
||||
}))
|
||||
|
||||
@ -201,6 +216,7 @@ describe('agent/panel', () => {
|
||||
mockPromptEditorProps.length = 0
|
||||
mockOrchestrateDrawerPanelProps.length = 0
|
||||
mockStoreState.openInlineAgentPanelNodeId = undefined
|
||||
mockCreateInlineAgentBinding.mockImplementation(() => {})
|
||||
mockUseNodeCrud.mockImplementation((_id: string, data: AgentV2NodeType) => ({
|
||||
inputs: data,
|
||||
setInputs: mockSetInputs,
|
||||
@ -346,14 +362,17 @@ describe('agent/panel', () => {
|
||||
expect(mockUseAgentRosterDetail).toHaveBeenCalledWith(undefined)
|
||||
expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1')
|
||||
expect(screen.queryByText('Workflow Agent 1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.name')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument()
|
||||
const trigger = screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ })
|
||||
expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.name')).toBeInTheDocument()
|
||||
expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument()
|
||||
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
|
||||
const panelRobotIcon = panel.querySelector('.i-custom-vender-agent-v2-robot-3')
|
||||
expect(container.querySelector('.i-custom-vender-agent-v2-robot-3')).toHaveClass('size-5')
|
||||
expect(container.querySelector('.i-custom-vender-agent-v2-robot-3')?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
|
||||
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.title')).toBeInTheDocument()
|
||||
expect(panelRobotIcon).toHaveClass('size-5')
|
||||
expect(panelRobotIcon?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
|
||||
expect(screen.queryByText('workflow.nodes.agent.roster.inlineSetup.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.title' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
|
||||
expect(mockOrchestrateDrawerPanelProps.at(-1)).toMatchObject({
|
||||
agentId: 'inline-agent-1',
|
||||
@ -399,9 +418,11 @@ describe('agent/panel', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: 'Workflow Agent 1' })
|
||||
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
|
||||
expect(panel).toBeInTheDocument()
|
||||
expect(panel.querySelector('header')).not.toHaveClass('h-[108px]')
|
||||
expect(panel.querySelector('.i-custom-vender-agent-v2-robot-3')?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
|
||||
expect(within(panel).queryByText('Workflow Agent 1')).not.toBeInTheDocument()
|
||||
expect(within(panel).getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument()
|
||||
expect(within(panel).queryByText('workflow.nodes.agent.roster.inlineSetup.title')).not.toBeInTheDocument()
|
||||
expect(within(panel).queryByText('workflow.nodes.agent.roster.inlineSetup.description')).not.toBeInTheDocument()
|
||||
@ -410,6 +431,26 @@ describe('agent/panel', () => {
|
||||
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show start from scratch for an existing inline agent binding', () => {
|
||||
render(
|
||||
<AgentV2Panel
|
||||
id="agent-node"
|
||||
data={createData({
|
||||
agent_binding: {
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-agent-1',
|
||||
current_snapshot_id: 'snapshot-1',
|
||||
},
|
||||
})}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change' }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Start from Scratch' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the inline panel while workflow composer state is still loading', () => {
|
||||
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
|
||||
mockUseWorkflowInlineAgentDetail.mockReturnValue({ data: undefined })
|
||||
@ -430,8 +471,7 @@ describe('agent/panel', () => {
|
||||
|
||||
expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1')
|
||||
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.nodes.agent.roster.inlineSetup.name')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.title' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
|
||||
expect(mockOrchestrateDrawerPanelProps.at(-1)).toMatchObject({
|
||||
agentId: 'inline-agent-1',
|
||||
@ -498,6 +538,66 @@ describe('agent/panel', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('switches a roster agent to a workflow-only inline agent from the selector', () => {
|
||||
mockCreateInlineAgentBinding.mockImplementation((_nodeId: string, options?: {
|
||||
onSuccess?: (binding: {
|
||||
binding_type: 'inline_agent'
|
||||
agent_id: string
|
||||
current_snapshot_id: string
|
||||
}) => void
|
||||
}) => {
|
||||
options?.onSuccess?.({
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-agent-1',
|
||||
current_snapshot_id: 'inline-snapshot-1',
|
||||
})
|
||||
})
|
||||
|
||||
render(
|
||||
<AgentV2Panel
|
||||
id="agent-node"
|
||||
data={createData({
|
||||
agent_task: 'Keep this task',
|
||||
agent_declared_outputs: [{
|
||||
name: 'summary',
|
||||
type: 'string',
|
||||
}],
|
||||
})}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start from Scratch' }))
|
||||
|
||||
expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith('agent-node', expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node')
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'agent-node',
|
||||
data: expect.objectContaining({
|
||||
agent_binding: {
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-agent-1',
|
||||
current_snapshot_id: 'inline-snapshot-1',
|
||||
},
|
||||
agent_task: 'Keep this task',
|
||||
agent_declared_outputs: [{
|
||||
name: 'summary',
|
||||
type: 'string',
|
||||
}],
|
||||
_openInlineAgentPanel: true,
|
||||
}),
|
||||
},
|
||||
expect.objectContaining({
|
||||
sync: true,
|
||||
notRefreshWhenSyncError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fall back to the roster agent description when role is empty', () => {
|
||||
mockUseAgentRosterDetail.mockReturnValue({
|
||||
data: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user