Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90fbe990c0 | ||
|
|
f72b31b346 | ||
|
|
ebc589915b | ||
|
|
8e89d640b0 | ||
|
|
ab76142a65 | ||
|
|
3d8bdc54ff | ||
|
|
c8d2a0febf | ||
|
|
8ad19ac84e | ||
|
|
9573e1c842 | ||
|
|
5469f2c930 | ||
|
|
e548fe0460 | ||
|
|
185903fad1 | ||
|
|
08bde4842d | ||
|
|
049f104390 | ||
|
|
07623d299d | ||
|
|
c30b39cd2e | ||
|
|
c425603e6f | ||
|
|
4cb1666252 | ||
|
|
f507c65397 | ||
|
|
f1fa71c15e | ||
|
|
d0e6b28c8f | ||
|
|
1f33f46a0f | ||
|
|
89049c1714 | ||
|
|
cc71c3a2c3 | ||
|
|
76f3b757ef | ||
|
|
11084104f4 | ||
|
|
e3aee7be71 | ||
|
|
e4bab0f26f | ||
|
|
c3bd42ab93 | ||
|
|
e7bd444044 | ||
|
|
e6eedd6044 | ||
|
|
0262fff48d | ||
|
|
9eceda40b1 | ||
|
|
76af36abb2 | ||
|
|
42772a27f6 | ||
|
|
2aea989971 | ||
|
|
14a8f1870b | ||
|
|
c76461ea56 | ||
|
|
09b9efced2 | ||
|
|
f2a3b74321 | ||
|
|
b742ff7335 | ||
|
|
ac20089dfa | ||
|
|
c2541f36cb | ||
|
|
be43316404 | ||
|
|
7a2ee67a5a | ||
|
|
5b22648ec0 | ||
|
|
1c5839e351 | ||
|
|
9840dd1e2b | ||
|
|
f8309f3311 | ||
|
|
57bae9e69f | ||
|
|
9fcb267dfe | ||
|
|
9f313d4a05 | ||
|
|
8d2a0269fe | ||
|
|
07b930f77c | ||
|
|
d285d64d8b | ||
|
|
bed0e59c2e | ||
|
|
2ef2061b26 | ||
|
|
da2e3e71b6 | ||
|
|
5f876135f7 | ||
|
|
0ca0229a9e | ||
|
|
3653bab490 | ||
|
|
bb469e17ca | ||
|
|
ed4f3b213c | ||
|
|
cc6f379900 | ||
|
|
590dd67501 | ||
|
|
48ff55236b | ||
|
|
df3ac41d29 | ||
|
|
4608bde818 | ||
|
|
137d618870 | ||
|
|
53e088cc32 | ||
|
|
1dafe37da8 | ||
|
|
13d9fd7826 | ||
|
|
4a083649b5 | ||
|
|
6ebf5f0c74 | ||
|
|
fcad148ff3 | ||
|
|
df9006f01b |
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -3,10 +3,12 @@ from uuid import UUID
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
|
||||
from controllers.console.app.app import (
|
||||
AppDetailWithSite as GenericAppDetailWithSite,
|
||||
)
|
||||
@@ -25,9 +27,13 @@ from controllers.console.app.app import (
|
||||
UpdateAppPayload as GenericUpdateAppPayload,
|
||||
)
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
enterprise_license_required,
|
||||
is_admin_or_owner_required,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
@@ -49,7 +55,8 @@ from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import IconType
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, IconType
|
||||
from services.agent.errors import AgentNotFoundError
|
||||
from services.agent.observability_service import (
|
||||
AgentLogQueryParams,
|
||||
@@ -103,6 +110,27 @@ class AgentAppUpdatePayload(GenericUpdateAppPayload):
|
||||
return role
|
||||
|
||||
|
||||
class AgentApiStatusPayload(BaseModel):
|
||||
enable_api: bool = Field(..., description="Enable or disable Agent service API")
|
||||
|
||||
|
||||
class AgentApiAccessResponse(BaseModel):
|
||||
enabled: bool
|
||||
service_api_base_url: str
|
||||
streaming_only: bool = True
|
||||
chat_endpoint: str
|
||||
stop_endpoint: str
|
||||
conversations_endpoint: str
|
||||
messages_endpoint: str
|
||||
files_upload_endpoint: str
|
||||
parameters_endpoint: str
|
||||
info_endpoint: str
|
||||
meta_endpoint: str
|
||||
api_rpm: int
|
||||
api_rph: int
|
||||
api_key_count: int
|
||||
|
||||
|
||||
class AgentAppPublishedReferenceResponse(BaseModel):
|
||||
app_id: str
|
||||
app_name: str
|
||||
@@ -210,6 +238,7 @@ register_schema_models(
|
||||
console_ns,
|
||||
AgentAppCreatePayload,
|
||||
AgentAppUpdatePayload,
|
||||
AgentApiStatusPayload,
|
||||
CopyAppPayload,
|
||||
AgentInviteOptionsQuery,
|
||||
AgentLogsQuery,
|
||||
@@ -221,6 +250,7 @@ register_schema_models(
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentAppPagination,
|
||||
AgentApiAccessResponse,
|
||||
AgentAppPublishedReferenceResponse,
|
||||
AgentAppDetailWithSite,
|
||||
AgentAppPartial,
|
||||
@@ -241,7 +271,7 @@ def _agent_roster_service() -> AgentRosterService:
|
||||
return AgentRosterService(db.session)
|
||||
|
||||
|
||||
def _serialize_agent_app_detail(app_model) -> dict:
|
||||
def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
|
||||
"""Serialize an Agent App detail using roster-only DTOs.
|
||||
|
||||
`/agent` responses are roster-shaped rather than raw app-shaped: `id`
|
||||
@@ -264,7 +294,11 @@ def _serialize_agent_app_detail(app_model) -> dict:
|
||||
payload.pop("bound_agent_id", None)
|
||||
payload["app_id"] = str(app_model.id)
|
||||
payload["id"] = agent.id
|
||||
payload["debug_conversation_id"] = agent.debug_conversation_id
|
||||
payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent.id,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
payload["role"] = agent.role or ""
|
||||
payload["active_config_is_published"] = roster_service.active_config_is_published(
|
||||
tenant_id=app_model.tenant_id,
|
||||
@@ -273,7 +307,7 @@ def _serialize_agent_app_detail(app_model) -> dict:
|
||||
return payload
|
||||
|
||||
|
||||
def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
|
||||
def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_user: Account) -> dict:
|
||||
"""Serialize Agent App lists with roster-shaped items.
|
||||
|
||||
Each item starts from the shared App list shape, then drops
|
||||
@@ -296,6 +330,11 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=[agent.id for agent in agents_by_app_id.values()],
|
||||
)
|
||||
debug_conversation_ids_by_agent_id = roster_service.load_or_create_agent_app_debug_conversation_ids_by_agent_id(
|
||||
tenant_id=tenant_id,
|
||||
agents=list(agents_by_app_id.values()),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
payload = AgentAppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json")
|
||||
for item in payload["data"]:
|
||||
app_id = item["id"]
|
||||
@@ -304,7 +343,7 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
|
||||
if agent:
|
||||
item["app_id"] = app_id
|
||||
item["id"] = agent.id
|
||||
item["debug_conversation_id"] = agent.debug_conversation_id
|
||||
item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id)
|
||||
item["role"] = agent.role or ""
|
||||
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
|
||||
published_references = published_references_by_agent_id.get(agent.id, [])
|
||||
@@ -329,6 +368,38 @@ def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
|
||||
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
|
||||
|
||||
def _agent_api_key_count(app_id: str) -> int:
|
||||
return (
|
||||
db.session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
ApiToken.type == ApiTokenType.APP,
|
||||
ApiToken.app_id == app_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def _serialize_agent_api_access(app_model: App) -> dict:
|
||||
base_url = app_model.api_base_url
|
||||
response = AgentApiAccessResponse(
|
||||
enabled=bool(app_model.enable_api),
|
||||
service_api_base_url=base_url,
|
||||
chat_endpoint=f"{base_url}/chat-messages",
|
||||
stop_endpoint=f"{base_url}/chat-messages/{{task_id}}/stop",
|
||||
conversations_endpoint=f"{base_url}/conversations",
|
||||
messages_endpoint=f"{base_url}/messages",
|
||||
files_upload_endpoint=f"{base_url}/files/upload",
|
||||
parameters_endpoint=f"{base_url}/parameters",
|
||||
info_endpoint=f"{base_url}/info",
|
||||
meta_endpoint=f"{base_url}/meta",
|
||||
api_rpm=app_model.api_rpm or 0,
|
||||
api_rph=app_model.api_rph or 0,
|
||||
api_key_count=_agent_api_key_count(str(app_model.id)),
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
|
||||
def _agent_observability_service() -> AgentObservabilityService:
|
||||
return AgentObservabilityService(db.session)
|
||||
|
||||
@@ -380,7 +451,11 @@ class AgentAppListApi(Resource):
|
||||
empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
return empty.model_dump(mode="json")
|
||||
|
||||
return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id)
|
||||
return _serialize_agent_app_pagination(
|
||||
app_pagination,
|
||||
tenant_id=current_tenant_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__])
|
||||
@console_ns.response(201, "Agent app created successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@@ -405,7 +480,7 @@ class AgentAppListApi(Resource):
|
||||
)
|
||||
|
||||
app = AppService().create_app(current_tenant_id, params, current_user)
|
||||
return _serialize_agent_app_detail(app), 201
|
||||
return _serialize_agent_app_detail(app, current_user=current_user), 201
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>")
|
||||
@@ -415,10 +490,11 @@ class AgentAppApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _serialize_agent_app_detail(app_model)
|
||||
return _serialize_agent_app_detail(app_model, current_user=current_user)
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@@ -428,8 +504,9 @@ class AgentAppApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, agent_id: UUID):
|
||||
def put(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
args = AgentAppUpdatePayload.model_validate(console_ns.payload)
|
||||
args_dict: AppService.ArgsDict = {
|
||||
@@ -443,7 +520,7 @@ class AgentAppApi(Resource):
|
||||
"role": args.role,
|
||||
}
|
||||
updated = AppService().update_app(app_model, args_dict)
|
||||
return _serialize_agent_app_detail(updated)
|
||||
return _serialize_agent_app_detail(updated, current_user=current_user)
|
||||
|
||||
@console_ns.response(204, "Agent app deleted successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@@ -482,7 +559,78 @@ class AgentAppCopyApi(Resource):
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
)
|
||||
return _serialize_agent_app_detail(copied_app), 201
|
||||
return _serialize_agent_app_detail(copied_app, current_user=current_user), 201
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/api-access")
|
||||
class AgentApiAccessApi(Resource):
|
||||
@console_ns.response(200, "Agent service API access", console_ns.models[AgentApiAccessResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _serialize_agent_api_access(app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/api-enable")
|
||||
class AgentApiStatusApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentApiStatusPayload.__name__])
|
||||
@console_ns.response(200, "Agent service API status updated", console_ns.models[AgentApiAccessResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
args = AgentApiStatusPayload.model_validate(console_ns.payload)
|
||||
app_model = AppService().update_app_api_status(app_model, args.enable_api)
|
||||
return _serialize_agent_api_access(app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/api-keys")
|
||||
class AgentApiKeyListApi(BaseApiKeyListResource):
|
||||
resource_type = ApiTokenType.APP
|
||||
resource_model = App
|
||||
resource_id_field = "app_id"
|
||||
token_prefix = "app-"
|
||||
|
||||
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID) -> dict[str, object]:
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return dump_response(ApiKeyList, self._get_api_key_list(str(app_model.id), tenant_id))
|
||||
|
||||
@console_ns.response(201, "Agent service API key created", console_ns.models[ApiKeyItem.__name__])
|
||||
@console_ns.response(400, "Maximum keys exceeded")
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
def post(self, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return dump_response(ApiKeyItem, self._create_api_key(str(app_model.id), tenant_id)), 201
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>")
|
||||
class AgentApiKeyApi(BaseApiKeyResource):
|
||||
resource_type = ApiTokenType.APP
|
||||
resource_model = App
|
||||
resource_id_field = "app_id"
|
||||
|
||||
@console_ns.response(204, "Agent service API key deleted")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
def delete(
|
||||
self, tenant_id: str, current_user: Account, agent_id: UUID, api_key_id: UUID
|
||||
) -> tuple[str, int]:
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
self._delete_api_key(str(app_model.id), str(api_key_id), tenant_id, current_user)
|
||||
return "", 204
|
||||
|
||||
|
||||
@console_ns.route("/agent/invite-options")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -266,7 +266,11 @@ class AgentDriveDownloadByAgentApi(Resource):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
key=query.key,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
@@ -40,12 +40,15 @@ from core.errors.error import (
|
||||
QuotaExceededError,
|
||||
)
|
||||
from core.helper.trace_id_helper import get_external_trace_id
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs import helper
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import App, AppMode
|
||||
from services.agent.errors import AgentNotFoundError
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.app_task_service import AppTaskService
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
@@ -191,10 +194,11 @@ class ChatMessageApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT])
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
return _create_chat_message(current_user=current_user, app_model=app_model)
|
||||
def post(self, current_tenant_id: str, current_user: Account, app_model: App):
|
||||
return _create_chat_message(current_tenant_id=current_tenant_id, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
|
||||
@@ -215,7 +219,12 @@ class AgentChatMessageApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _create_chat_message(current_user=current_user, app_model=app_model)
|
||||
return _create_chat_message(
|
||||
current_tenant_id=current_tenant_id,
|
||||
current_user=current_user,
|
||||
app_model=app_model,
|
||||
agent_id=str(agent_id),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<string:task_id>/stop")
|
||||
@@ -249,11 +258,45 @@ class AgentChatMessageStopApi(Resource):
|
||||
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
|
||||
|
||||
|
||||
def _create_chat_message(*, current_user: Account, app_model: App):
|
||||
def _resolve_current_user_agent_debug_conversation_id(
|
||||
*, current_tenant_id: str, current_user: Account, app_model: App, agent_id: str | None
|
||||
) -> str:
|
||||
roster_service = AgentRosterService(db.session)
|
||||
if agent_id:
|
||||
return roster_service.get_or_create_agent_app_debug_conversation_id(
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
agent = roster_service.get_app_backing_agent(tenant_id=current_tenant_id, app_id=str(app_model.id))
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
return roster_service.get_or_create_agent_app_debug_conversation_id(
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=agent.id,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
def _create_chat_message(
|
||||
*, current_user: Account, app_model: App, current_tenant_id: str | None = None, agent_id: str | None = None
|
||||
):
|
||||
raw_payload = console_ns.payload or {}
|
||||
args_model = ChatMessagePayload.model_validate(raw_payload)
|
||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||
|
||||
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
|
||||
debug_conversation_id = _resolve_current_user_agent_debug_conversation_id(
|
||||
current_tenant_id=current_tenant_id or app_model.tenant_id,
|
||||
current_user=current_user,
|
||||
app_model=app_model,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
if args_model.conversation_id and args_model.conversation_id != debug_conversation_id:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
args["conversation_id"] = debug_conversation_id
|
||||
|
||||
streaming = _resolve_debugger_chat_streaming(
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
response_mode=args_model.response_mode,
|
||||
|
||||
@@ -53,6 +53,7 @@ from libs.login import login_required
|
||||
from models.account import Account
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
|
||||
from services.conversation_service import ConversationService
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
from services.message_service import MessageService, attach_message_extra_contents
|
||||
@@ -186,10 +187,11 @@ class ChatMessageListApi(Resource):
|
||||
@account_initialization_required
|
||||
@setup_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def get(self, app_model: App):
|
||||
return _list_chat_messages(app_model=app_model)
|
||||
def get(self, current_user: Account, app_model: App):
|
||||
return _list_chat_messages(app_model=app_model, current_user=current_user)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
|
||||
@@ -205,10 +207,11 @@ class AgentChatMessageListApi(Resource):
|
||||
@setup_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, agent_id: UUID):
|
||||
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _list_chat_messages(app_model=app_model)
|
||||
return _list_chat_messages(app_model=app_model, current_user=current_user)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/feedbacks")
|
||||
@@ -390,14 +393,24 @@ class AgentMessageApi(Resource):
|
||||
return _get_message_detail(app_model=app_model, message_id=message_id)
|
||||
|
||||
|
||||
def _list_chat_messages(*, app_model: App):
|
||||
def _list_chat_messages(*, app_model: App, current_user: Account | None = None):
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||
|
||||
conversation = db.session.scalar(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
|
||||
.limit(1)
|
||||
)
|
||||
if AppMode.value_of(app_model.mode) == AppMode.AGENT and current_user is not None:
|
||||
try:
|
||||
conversation = ConversationService.get_conversation(
|
||||
app_model=app_model,
|
||||
conversation_id=args.conversation_id,
|
||||
user=current_user,
|
||||
)
|
||||
except ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
else:
|
||||
conversation = db.session.scalar(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import Any, cast
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from controllers.common.fields import Parameters
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
@@ -9,7 +10,11 @@ from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
from controllers.service_api.wraps import validate_app_token
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, AppMode
|
||||
from services.app_service import AppService
|
||||
|
||||
@@ -29,6 +34,40 @@ class AppMetaResponse(ResponseModel):
|
||||
register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, AppInfoResponse)
|
||||
|
||||
|
||||
def _get_agent_app_feature_dict_and_user_input_form(app_model: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
app_model_config = app_model.app_model_config
|
||||
features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {}
|
||||
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
Agent.app_id == app_model.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
raise AppUnavailableError()
|
||||
|
||||
snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot)
|
||||
.where(
|
||||
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent.id,
|
||||
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if snapshot is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
|
||||
|
||||
|
||||
@service_api_ns.route("/parameters")
|
||||
class AppParameterApi(Resource):
|
||||
"""Resource for app variables."""
|
||||
@@ -61,12 +100,16 @@ class AppParameterApi(Resource):
|
||||
|
||||
Returns the input form parameters and configuration for the application.
|
||||
"""
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
features_dict: dict[str, Any]
|
||||
user_input_form: list[dict[str, Any]]
|
||||
if app_model.mode == AppMode.AGENT:
|
||||
features_dict, user_input_form = _get_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow
|
||||
if workflow is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
features_dict: dict[str, Any] = workflow.features_dict
|
||||
features_dict = workflow.features_dict
|
||||
user_input_form = workflow.user_input_form(to_old_structure=True)
|
||||
else:
|
||||
app_model_config = app_model.app_model_config
|
||||
|
||||
@@ -21,6 +21,7 @@ from core.app.app_config.entities import (
|
||||
EasyUIBasedAppModelConfigFrom,
|
||||
PromptTemplateEntity,
|
||||
)
|
||||
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, Conversation
|
||||
|
||||
@@ -98,8 +99,7 @@ class AgentAppConfigManager(BaseAppConfigManager):
|
||||
# pipeline's bookkeeping (token counting, persistence).
|
||||
base["prompt_type"] = PromptTemplateEntity.PromptType.SIMPLE.value
|
||||
base["pre_prompt"] = agent_soul.prompt.system_prompt or ""
|
||||
# Agent App takes the user message directly; no completion-style inputs form.
|
||||
base.setdefault("user_input_form", [])
|
||||
base["user_input_form"] = agent_app_variables_to_user_input_form(agent_soul.app_variables)
|
||||
return base
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from models.agent_config_entities import AppVariableConfig
|
||||
|
||||
|
||||
def agent_app_variables_to_user_input_form(app_variables: Sequence[AppVariableConfig]) -> list[dict[str, Any]]:
|
||||
"""Project Agent Soul app variables into the legacy service-API parameter form."""
|
||||
|
||||
user_input_form: list[dict[str, Any]] = []
|
||||
for variable in app_variables:
|
||||
form_type = _form_type_for_agent_variable(variable.type)
|
||||
form_item: dict[str, Any] = {
|
||||
"label": variable.name,
|
||||
"variable": variable.name,
|
||||
"required": variable.required,
|
||||
}
|
||||
if variable.default is not None:
|
||||
form_item["default"] = variable.default
|
||||
user_input_form.append({form_type: form_item})
|
||||
return user_input_form
|
||||
|
||||
|
||||
def _form_type_for_agent_variable(variable_type: str) -> str:
|
||||
normalized = variable_type.strip().lower()
|
||||
if normalized in {"number", "integer", "float"}:
|
||||
return "number"
|
||||
if normalized in {"boolean", "bool"}:
|
||||
return "checkbox"
|
||||
if normalized in {"paragraph", "long_text", "multiline"}:
|
||||
return "paragraph"
|
||||
return "text-input"
|
||||
|
||||
|
||||
__all__ = ["agent_app_variables_to_user_input_form"]
|
||||
@@ -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")}
|
||||
|
||||
+44
-1
@@ -6,9 +6,15 @@ Create Date: 2026-06-18 23:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.engine.mock import MockConnection
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b2515f9d4c2a"
|
||||
@@ -31,9 +37,46 @@ def upgrade() -> None:
|
||||
"agent_drive_files",
|
||||
["tenant_id", "agent_id", "is_skill", "key"],
|
||||
)
|
||||
_remove_skills_files_from_snapshots()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files")
|
||||
op.drop_column("agent_drive_files", "skill_metadata")
|
||||
op.drop_column("agent_drive_files", "is_skill")
|
||||
|
||||
|
||||
def _remove_skills_files_from_snapshots() -> None:
|
||||
connection = op.get_bind()
|
||||
if connection is None or isinstance(connection, MockConnection):
|
||||
return
|
||||
snapshots = sa.table(
|
||||
"agent_config_snapshots",
|
||||
sa.column("id", sa.String()),
|
||||
sa.column("config_snapshot", sa.Text()),
|
||||
)
|
||||
rows = connection.execute(sa.select(snapshots.c.id, snapshots.c.config_snapshot)).fetchall()
|
||||
for row in rows:
|
||||
cleaned = _strip_skills_files(row.config_snapshot)
|
||||
if cleaned is None:
|
||||
continue
|
||||
connection.execute(
|
||||
snapshots.update()
|
||||
.where(snapshots.c.id == row.id)
|
||||
.values(config_snapshot=json.dumps(cleaned, separators=(",", ":"), sort_keys=True))
|
||||
)
|
||||
|
||||
|
||||
def _strip_skills_files(raw_snapshot: Any) -> dict[str, Any] | None:
|
||||
if raw_snapshot is None:
|
||||
return None
|
||||
if isinstance(raw_snapshot, str):
|
||||
snapshot = json.loads(raw_snapshot)
|
||||
elif isinstance(raw_snapshot, dict):
|
||||
snapshot = dict(raw_snapshot)
|
||||
else:
|
||||
snapshot = dict(raw_snapshot)
|
||||
if not isinstance(snapshot, dict) or "skills_files" not in snapshot:
|
||||
return None
|
||||
snapshot.pop("skills_files", None)
|
||||
return snapshot
|
||||
|
||||
+43
-7
@@ -1,4 +1,4 @@
|
||||
"""add agent debug conversation id
|
||||
"""add agent debug conversations
|
||||
|
||||
Revision ID: c8f4a6b2d3e1
|
||||
Revises: b2515f9d4c2a
|
||||
@@ -18,13 +18,49 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _is_pg(conn) -> bool:
|
||||
return conn.dialect.name == "postgresql"
|
||||
|
||||
|
||||
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
|
||||
kwargs = {"nullable": nullable, "primary_key": primary_key}
|
||||
if primary_key and _is_pg(op.get_bind()):
|
||||
kwargs["server_default"] = sa.text("uuidv7()")
|
||||
return sa.Column(name, models.types.StringUUID(), **kwargs)
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("debug_conversation_id", models.types.StringUUID(), nullable=True))
|
||||
batch_op.create_index("agent_debug_conversation_id_idx", ["debug_conversation_id"], unique=False)
|
||||
op.create_table(
|
||||
"agent_debug_conversations",
|
||||
_uuid_column("id", primary_key=True),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("account_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("conversation_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("agent_debug_conversation_pkey")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"agent_id",
|
||||
"account_id",
|
||||
name=op.f("agent_debug_conversation_agent_account_unique"),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"agent_debug_conversation_conversation_idx",
|
||||
"agent_debug_conversations",
|
||||
["conversation_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"agent_debug_conversation_account_idx",
|
||||
"agent_debug_conversations",
|
||||
["tenant_id", "account_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("agents", schema=None) as batch_op:
|
||||
batch_op.drop_index("agent_debug_conversation_id_idx")
|
||||
batch_op.drop_column("debug_conversation_id")
|
||||
op.drop_index("agent_debug_conversation_account_idx", table_name="agent_debug_conversations")
|
||||
op.drop_index("agent_debug_conversation_conversation_idx", table_name="agent_debug_conversations")
|
||||
op.drop_table("agent_debug_conversations")
|
||||
|
||||
@@ -13,6 +13,7 @@ from .agent import (
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
AgentDebugConversation,
|
||||
AgentDriveFile,
|
||||
AgentDriveFileKind,
|
||||
AgentIconType,
|
||||
@@ -156,6 +157,7 @@ __all__ = [
|
||||
"AgentConfigRevision",
|
||||
"AgentConfigRevisionOperation",
|
||||
"AgentConfigSnapshot",
|
||||
"AgentDebugConversation",
|
||||
"AgentDriveFile",
|
||||
"AgentDriveFileKind",
|
||||
"AgentIconType",
|
||||
|
||||
+28
-2
@@ -135,7 +135,6 @@ class Agent(DefaultFieldsMixin, Base):
|
||||
Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"),
|
||||
Index("agent_tenant_app_id_idx", "tenant_id", "app_id"),
|
||||
Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"),
|
||||
Index("agent_debug_conversation_id_idx", "debug_conversation_id"),
|
||||
Index(
|
||||
"agent_tenant_invitable_idx",
|
||||
"tenant_id",
|
||||
@@ -163,7 +162,6 @@ class Agent(DefaultFieldsMixin, Base):
|
||||
scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False)
|
||||
source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False)
|
||||
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
debug_conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
@@ -184,6 +182,34 @@ class Agent(DefaultFieldsMixin, Base):
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class AgentDebugConversation(DefaultFieldsMixin, Base):
|
||||
"""Per-account console debug conversation for an Agent App.
|
||||
|
||||
Agent App preview state must be isolated by editor account. The Agent row is
|
||||
shared by everyone in the workspace, so this table owns the user-specific
|
||||
conversation pointer used by console debug chat.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_debug_conversations"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="agent_debug_conversation_pkey"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"agent_id",
|
||||
"account_id",
|
||||
name="agent_debug_conversation_agent_account_unique",
|
||||
),
|
||||
Index("agent_debug_conversation_conversation_idx", "conversation_id"),
|
||||
Index("agent_debug_conversation_account_idx", "tenant_id", "account_id"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
|
||||
|
||||
class AgentConfigSnapshot(DefaultFieldsMixin, Base):
|
||||
"""Immutable Agent Soul 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)
|
||||
|
||||
@@ -1534,7 +1534,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
|
||||
|
||||
@@ -1620,7 +1620,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
|
||||
|
||||
@@ -12293,23 +12293,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 |
|
||||
@@ -12334,22 +12317,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 |
|
||||
@@ -12358,7 +12325,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
|
||||
|
||||
@@ -12473,7 +12439,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 |
|
||||
|
||||
@@ -12487,7 +12452,6 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| config_version_id | string | | No |
|
||||
| file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes |
|
||||
|
||||
#### AgentDriveFilePayload
|
||||
@@ -13073,27 +13037,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
|
||||
|
||||
@@ -13122,7 +13071,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
|
||||
@@ -13239,13 +13187,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 |
|
||||
@@ -13377,6 +13318,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)."""
|
||||
@@ -891,7 +713,9 @@ class AgentComposerService:
|
||||
return binding
|
||||
|
||||
@classmethod
|
||||
def _is_start_from_scratch_request(cls, *, binding: WorkflowAgentNodeBinding, payload: ComposerSavePayload) -> bool:
|
||||
def _is_start_from_scratch_request(
|
||||
cls, *, binding: WorkflowAgentNodeBinding, payload: ComposerSavePayload
|
||||
) -> bool:
|
||||
return (
|
||||
binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
|
||||
and payload.binding is not None
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,7 @@ from models.agent import (
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
AgentDebugConversation,
|
||||
AgentKind,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
@@ -97,7 +98,7 @@ class AgentRosterService:
|
||||
"scope": agent.scope.value,
|
||||
"source": agent.source.value,
|
||||
"app_id": agent.app_id,
|
||||
"debug_conversation_id": getattr(agent, "debug_conversation_id", None),
|
||||
"debug_conversation_id": None,
|
||||
"workflow_id": agent.workflow_id,
|
||||
"workflow_node_id": agent.workflow_node_id,
|
||||
"active_config_snapshot_id": agent.active_config_snapshot_id,
|
||||
@@ -393,15 +394,12 @@ class AgentRosterService:
|
||||
self._session.add(revision)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(AgentSoulConfig())
|
||||
agent.debug_conversation_id = self._create_agent_app_debug_conversation(
|
||||
app_id=app_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
self._session.flush()
|
||||
self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id)
|
||||
return agent
|
||||
|
||||
def _create_agent_app_debug_conversation(self, *, app_id: str, account_id: str) -> str:
|
||||
"""Create the stable console conversation used by Agent App debug mode."""
|
||||
"""Create one console debug conversation for an Agent App editor."""
|
||||
|
||||
conversation = Conversation(
|
||||
app_id=app_id,
|
||||
@@ -425,6 +423,98 @@ class AgentRosterService:
|
||||
self._session.flush()
|
||||
return conversation.id
|
||||
|
||||
def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str:
|
||||
if not agent.app_id:
|
||||
raise AgentNotFoundError()
|
||||
|
||||
mapping = self._session.scalar(
|
||||
select(AgentDebugConversation).where(
|
||||
AgentDebugConversation.tenant_id == agent.tenant_id,
|
||||
AgentDebugConversation.agent_id == agent.id,
|
||||
AgentDebugConversation.account_id == account_id,
|
||||
)
|
||||
)
|
||||
if mapping is not None:
|
||||
conversation_id = self._session.scalar(
|
||||
select(Conversation.id).where(
|
||||
Conversation.id == mapping.conversation_id,
|
||||
Conversation.app_id == agent.app_id,
|
||||
Conversation.from_source == ConversationFromSource.CONSOLE,
|
||||
Conversation.from_account_id == account_id,
|
||||
Conversation.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if conversation_id:
|
||||
return conversation_id
|
||||
|
||||
mapping.conversation_id = self._create_agent_app_debug_conversation(
|
||||
app_id=agent.app_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
self._session.flush()
|
||||
return mapping.conversation_id
|
||||
|
||||
conversation_id = self._create_agent_app_debug_conversation(
|
||||
app_id=agent.app_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
self._session.add(
|
||||
AgentDebugConversation(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
app_id=agent.app_id,
|
||||
account_id=account_id,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
)
|
||||
self._session.flush()
|
||||
return conversation_id
|
||||
|
||||
def get_or_create_agent_app_debug_conversation_id(
|
||||
self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True
|
||||
) -> str:
|
||||
"""Return the current editor's debug conversation for an Agent App."""
|
||||
|
||||
agent = self._session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
|
||||
conversation_id = self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id)
|
||||
if commit:
|
||||
self._session.commit()
|
||||
return conversation_id
|
||||
|
||||
def load_or_create_agent_app_debug_conversation_ids_by_agent_id(
|
||||
self, *, tenant_id: str, agents: list[Agent], account_id: str
|
||||
) -> dict[str, str]:
|
||||
"""Return per-account debug conversations for a page of Agent Apps."""
|
||||
|
||||
conversation_ids_by_agent_id: dict[str, str] = {}
|
||||
changed = False
|
||||
for agent in agents:
|
||||
if (
|
||||
agent.tenant_id != tenant_id
|
||||
or agent.scope != AgentScope.ROSTER
|
||||
or agent.source != AgentSource.AGENT_APP
|
||||
):
|
||||
continue
|
||||
conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation(
|
||||
agent=agent,
|
||||
account_id=account_id,
|
||||
)
|
||||
changed = True
|
||||
if changed:
|
||||
self._session.commit()
|
||||
return conversation_ids_by_agent_id
|
||||
|
||||
def load_app_backing_agents_by_app_id(self, *, tenant_id: str, app_ids: list[str]) -> dict[str, Agent]:
|
||||
"""Return active app-backed Agents keyed by Agent App id."""
|
||||
if not app_ids:
|
||||
|
||||
@@ -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
|
||||
@@ -21,7 +22,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
|
||||
|
||||
@@ -106,26 +106,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
|
||||
@@ -339,7 +340,13 @@ class AgentDriveService:
|
||||
return skills
|
||||
|
||||
def inspect_skill(self, *, tenant_id: str, agent_id: str, skill_path: str) -> AgentDriveSkillInspectInfo:
|
||||
"""Return the UI-facing skill inspect view for slash-menu hover/detail."""
|
||||
"""Return the UI-facing skill inspect view for slash-menu hover/detail.
|
||||
|
||||
Skill packages are normalized when committed to the drive, so this endpoint
|
||||
intentionally returns the standardized view: canonical ``SKILL.md`` as the
|
||||
source of truth plus the original package member list recorded in Agent
|
||||
Soul when available. It is not a raw archive echo endpoint.
|
||||
"""
|
||||
|
||||
skill_path = normalize_drive_key(skill_path)
|
||||
skill_md_key = skill_path if skill_path.endswith(_SKILL_MD_SUFFIX) else f"{skill_path}{_SKILL_MD_SUFFIX}"
|
||||
@@ -385,6 +392,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 +463,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 +805,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:
|
||||
@@ -855,6 +915,9 @@ class AgentDriveService:
|
||||
__all__ = [
|
||||
"AgentDriveError",
|
||||
"AgentDriveService",
|
||||
"AgentDriveSkillFileInfo",
|
||||
"AgentDriveSkillInfo",
|
||||
"AgentDriveSkillInspectInfo",
|
||||
"DriveCommitItem",
|
||||
"DriveFileRef",
|
||||
"DriveSkillMetadata",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -20,6 +20,10 @@ from controllers.console.agent.composer import (
|
||||
WorkflowAgentComposerValidateApi,
|
||||
)
|
||||
from controllers.console.agent.roster import (
|
||||
AgentApiAccessApi,
|
||||
AgentApiKeyApi,
|
||||
AgentApiKeyListApi,
|
||||
AgentApiStatusApi,
|
||||
AgentAppApi,
|
||||
AgentAppCopyApi,
|
||||
AgentAppListApi,
|
||||
@@ -150,6 +154,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
|
||||
"/agent/<uuid:agent_id>/sandbox/files",
|
||||
"/agent/<uuid:agent_id>/skills/upload",
|
||||
"/agent/<uuid:agent_id>/files",
|
||||
"/agent/<uuid:agent_id>/api-access",
|
||||
"/agent/<uuid:agent_id>/api-enable",
|
||||
"/agent/<uuid:agent_id>/api-keys",
|
||||
"/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>",
|
||||
"/agent/<uuid:agent_id>/chat-messages",
|
||||
"/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop",
|
||||
"/agent/<uuid:agent_id>/feedbacks",
|
||||
@@ -177,6 +185,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
|
||||
"/apps/<uuid:app_id>/agent-features",
|
||||
"/apps/<uuid:app_id>/agent-referencing-workflows",
|
||||
"/apps/<uuid:app_id>/agent-sandbox/files",
|
||||
"/apps/<uuid:agent_id>/api-access",
|
||||
):
|
||||
assert route not in paths
|
||||
|
||||
@@ -237,6 +246,11 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
active_config_snapshot_id=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"get_or_create_agent_app_debug_conversation_id",
|
||||
lambda _self, **kwargs: "debug-conversation-detail",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"load_published_references_by_agent_id",
|
||||
@@ -257,6 +271,16 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"load_or_create_agent_app_debug_conversation_ids_by_agent_id",
|
||||
lambda _self, **kwargs: {"agent-list": "debug-conversation-list"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"get_or_create_agent_app_debug_conversation_id",
|
||||
lambda _self, **kwargs: "debug-conversation-created",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.FeatureService,
|
||||
"get_system_features",
|
||||
@@ -373,7 +397,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
|
||||
|
||||
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
|
||||
|
||||
detail = unwrap(AgentAppApi.get)(AgentAppApi(), "tenant-1", agent_id)
|
||||
detail = unwrap(AgentAppApi.get)(AgentAppApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id)
|
||||
assert detail["id"] == agent_id
|
||||
assert detail["app_id"] == "app-1"
|
||||
assert detail["debug_conversation_id"] == "debug-conversation-detail"
|
||||
@@ -385,7 +409,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001",
|
||||
json={"name": "Renamed", "description": "", "role": "Reviewer", "icon_type": "emoji", "icon": "R"},
|
||||
):
|
||||
updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id)
|
||||
updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id)
|
||||
|
||||
assert updated["name"] == "Renamed"
|
||||
assert updated["id"] == agent_id
|
||||
@@ -420,7 +444,7 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
|
||||
monkeypatch.setattr(
|
||||
roster_controller,
|
||||
"_serialize_agent_app_detail",
|
||||
lambda app_model: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name},
|
||||
lambda app_model, **_kwargs: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name},
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
@@ -449,6 +473,127 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
|
||||
}
|
||||
|
||||
|
||||
def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
app_model = SimpleNamespace(
|
||||
id="app-1",
|
||||
enable_api=True,
|
||||
api_base_url="https://api.example.test/v1",
|
||||
api_rpm=60,
|
||||
api_rph=600,
|
||||
)
|
||||
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda app_id: 2)
|
||||
|
||||
response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), "tenant-1", agent_id)
|
||||
|
||||
assert response == {
|
||||
"enabled": True,
|
||||
"service_api_base_url": "https://api.example.test/v1",
|
||||
"streaming_only": True,
|
||||
"chat_endpoint": "https://api.example.test/v1/chat-messages",
|
||||
"stop_endpoint": "https://api.example.test/v1/chat-messages/{task_id}/stop",
|
||||
"conversations_endpoint": "https://api.example.test/v1/conversations",
|
||||
"messages_endpoint": "https://api.example.test/v1/messages",
|
||||
"files_upload_endpoint": "https://api.example.test/v1/files/upload",
|
||||
"parameters_endpoint": "https://api.example.test/v1/parameters",
|
||||
"info_endpoint": "https://api.example.test/v1/info",
|
||||
"meta_endpoint": "https://api.example.test/v1/meta",
|
||||
"api_rpm": 60,
|
||||
"api_rph": 600,
|
||||
"api_key_count": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_agent_api_status_and_key_routes_resolve_backing_app(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
api_key_id = "00000000-0000-0000-0000-000000000002"
|
||||
app_model = SimpleNamespace(
|
||||
id="app-1",
|
||||
enable_api=False,
|
||||
api_base_url="https://api.example.test/v1",
|
||||
api_rpm=0,
|
||||
api_rph=0,
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda app_id: 1)
|
||||
|
||||
class FakeAppService:
|
||||
def update_app_api_status(self, app_obj: object, enable_api: bool) -> object:
|
||||
captured["enable"] = {"app": app_obj, "enable_api": enable_api}
|
||||
app_model.enable_api = enable_api
|
||||
return app_model
|
||||
|
||||
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
|
||||
|
||||
def fake_get_api_key_list(self, resource_id: str, tenant_id: str):
|
||||
captured["list_keys"] = {"resource_id": resource_id, "tenant_id": tenant_id}
|
||||
return roster_controller.ApiKeyList(data=[])
|
||||
|
||||
def fake_create_api_key(self, resource_id: str, tenant_id: str):
|
||||
captured["create_key"] = {"resource_id": resource_id, "tenant_id": tenant_id}
|
||||
return SimpleNamespace(
|
||||
id=api_key_id,
|
||||
type="app",
|
||||
token="app-test-token",
|
||||
last_used_at=None,
|
||||
created_at=None,
|
||||
)
|
||||
|
||||
def fake_delete_api_key(self, resource_id: str, key_id: str, tenant_id: str, current_user: object) -> None:
|
||||
captured["delete_key"] = {
|
||||
"resource_id": resource_id,
|
||||
"api_key_id": key_id,
|
||||
"tenant_id": tenant_id,
|
||||
"current_user": current_user,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(AgentApiKeyListApi, "_get_api_key_list", fake_get_api_key_list)
|
||||
monkeypatch.setattr(AgentApiKeyListApi, "_create_api_key", fake_create_api_key)
|
||||
monkeypatch.setattr(AgentApiKeyApi, "_delete_api_key", fake_delete_api_key)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/api-enable",
|
||||
json={"enable_api": True},
|
||||
):
|
||||
enabled = unwrap(AgentApiStatusApi.post)(AgentApiStatusApi(), "tenant-1", agent_id)
|
||||
assert enabled["enabled"] is True
|
||||
assert captured["enable"] == {"app": app_model, "enable_api": True}
|
||||
|
||||
keys = unwrap(AgentApiKeyListApi.get)(AgentApiKeyListApi(), "tenant-1", agent_id)
|
||||
assert keys == {"data": []}
|
||||
assert captured["list_keys"] == {"resource_id": "app-1", "tenant_id": "tenant-1"}
|
||||
|
||||
created, status = unwrap(AgentApiKeyListApi.post)(AgentApiKeyListApi(), "tenant-1", agent_id)
|
||||
assert status == 201
|
||||
assert created["id"] == api_key_id
|
||||
assert created["token"] == "app-test-token"
|
||||
assert captured["create_key"] == {"resource_id": "app-1", "tenant_id": "tenant-1"}
|
||||
|
||||
current_user = SimpleNamespace(id="account-1", is_admin_or_owner=True)
|
||||
deleted, delete_status = unwrap(AgentApiKeyApi.delete)(
|
||||
AgentApiKeyApi(),
|
||||
"tenant-1",
|
||||
current_user,
|
||||
agent_id,
|
||||
api_key_id,
|
||||
)
|
||||
assert (deleted, delete_status) == ("", 204)
|
||||
assert captured["delete_key"] == {
|
||||
"resource_id": "app-1",
|
||||
"api_key_id": api_key_id,
|
||||
"tenant_id": "tenant-1",
|
||||
"current_user": current_user,
|
||||
}
|
||||
|
||||
|
||||
def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id)
|
||||
@@ -490,7 +635,7 @@ def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.Mon
|
||||
json={"name": "Renamed", "description": "", "role": "", "icon_type": "emoji", "icon": "R"},
|
||||
):
|
||||
with pytest.raises(ValueError, match="String should have at least 1 character"):
|
||||
unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id)
|
||||
unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id)
|
||||
|
||||
|
||||
def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@@ -86,6 +86,70 @@ def test_list_by_agent_filters_value_pointers_out_of_console_payload():
|
||||
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1"
|
||||
|
||||
|
||||
def test_skill_list_by_agent_calls_list_skills_and_returns_catalog_shape():
|
||||
raw = _raw(AgentDriveSkillListByAgentApi.get)
|
||||
with app.test_request_context("/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.list_skills.return_value = [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
}
|
||||
]
|
||||
body = raw(AgentDriveSkillListByAgentApi(), "tenant-1", "agent-1")
|
||||
|
||||
assert body == {
|
||||
"items": [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
}
|
||||
]
|
||||
}
|
||||
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert drive.return_value.list_skills.call_args.kwargs == {"tenant_id": "tenant-1", "agent_id": "agent-1"}
|
||||
|
||||
|
||||
def test_skill_inspect_by_agent_calls_inspect_skill_and_returns_detail_shape():
|
||||
raw = _raw(AgentDriveSkillInspectByAgentApi.get)
|
||||
with app.test_request_context("/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.inspect_skill.return_value = {
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"source": "skill_md",
|
||||
"files": [],
|
||||
"file_tree": [],
|
||||
"skill_md": {"key": "tender-analyzer/SKILL.md", "truncated": False, "binary": False, "text": "# hi"},
|
||||
"warnings": [],
|
||||
}
|
||||
response = raw(AgentDriveSkillInspectByAgentApi(), "tenant-1", "agent-1", "tender-analyzer")
|
||||
|
||||
body = response.get_json()
|
||||
assert body["source"] == "skill_md"
|
||||
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert drive.return_value.inspect_skill.call_args.kwargs == {
|
||||
"tenant_id": "tenant-1",
|
||||
"agent_id": "agent-1",
|
||||
"skill_path": "tender-analyzer",
|
||||
}
|
||||
|
||||
|
||||
def test_list_resolves_workflow_node_binding_agent():
|
||||
raw = _raw(AgentDriveListApi.get)
|
||||
with app.test_request_context("/?node_id=agent-node-1"):
|
||||
@@ -217,6 +281,8 @@ def test_skill_inspect_resolves_workflow_node_binding_agent():
|
||||
|
||||
assert response.get_json()["path"] == "pdf-toolkit"
|
||||
assert drive.return_value.inspect_skill.call_args.kwargs["agent_id"] == "wf-agent-9"
|
||||
assert drive.return_value.inspect_skill.call_args.kwargs["skill_path"] == "pdf-toolkit"
|
||||
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
|
||||
|
||||
|
||||
def test_list_400_when_no_agent_bound():
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -136,6 +136,55 @@ class TestAppParameterApi:
|
||||
assert "user_input_form" in response
|
||||
assert "opening_statement" in response
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.app.app._get_agent_app_feature_dict_and_user_input_form")
|
||||
def test_get_parameters_for_agent_app(
|
||||
self,
|
||||
mock_get_agent_parameters,
|
||||
mock_db,
|
||||
mock_validate_token,
|
||||
mock_current_app,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
mock_app_model,
|
||||
):
|
||||
"""Test retrieving parameters for an Agent App from Agent Soul app variables."""
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
mock_app_model.mode = AppMode.AGENT
|
||||
mock_app_model.app_model_config = None
|
||||
mock_app_model.workflow = None
|
||||
mock_get_agent_parameters.return_value = (
|
||||
{"opening_statement": "Hi from Agent"},
|
||||
[{"text-input": {"label": "topic", "variable": "topic", "required": True}}],
|
||||
)
|
||||
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = mock_app_model.id
|
||||
mock_api_token.tenant_id = mock_app_model.tenant_id
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.status = TenantStatus.NORMAL
|
||||
mock_db.session.get.side_effect = [mock_app_model, mock_tenant]
|
||||
|
||||
mock_account = Mock()
|
||||
mock_account.current_tenant = mock_tenant
|
||||
setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account)
|
||||
|
||||
with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
api = AppParameterApi()
|
||||
response = api.get()
|
||||
|
||||
assert response["opening_statement"] == "Hi from Agent"
|
||||
assert response["user_input_form"] == [
|
||||
{"text-input": {"label": "topic", "variable": "topic", "required": True}}
|
||||
]
|
||||
mock_get_agent_parameters.assert_called_once_with(mock_app_model)
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
|
||||
@@ -19,6 +19,10 @@ def _soul() -> AgentSoulConfig:
|
||||
"model_settings": {"temperature": 0.2},
|
||||
},
|
||||
"prompt": {"system_prompt": "You are Iris."},
|
||||
"app_variables": [
|
||||
{"name": "topic", "type": "string", "required": True},
|
||||
{"name": "count", "type": "number", "default": 3},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -32,7 +36,10 @@ def test_model_and_prompt_come_from_soul():
|
||||
"completion_params": {"temperature": 0.2},
|
||||
}
|
||||
assert d["pre_prompt"] == "You are Iris."
|
||||
assert d["user_input_form"] == []
|
||||
assert d["user_input_form"] == [
|
||||
{"text-input": {"label": "topic", "variable": "topic", "required": True}},
|
||||
{"number": {"label": "count", "variable": "count", "required": False, "default": 3}},
|
||||
]
|
||||
|
||||
|
||||
def test_feature_flags_come_from_app_model_config_when_present():
|
||||
|
||||
@@ -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
|
||||
|
||||
+143
-38
@@ -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)
|
||||
@@ -8,6 +8,7 @@ from models.agent import (
|
||||
Agent,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
AgentDebugConversation,
|
||||
AgentKind,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
@@ -16,7 +17,6 @@ from models.agent import (
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentFileRefConfig,
|
||||
DeclaredArrayItem,
|
||||
DeclaredOutputChildConfig,
|
||||
DeclaredOutputConfig,
|
||||
@@ -1448,7 +1448,6 @@ class TestAgentAppBackingAgent:
|
||||
assert agent.agent_kind == AgentKind.DIFY_AGENT
|
||||
assert agent.name == "Iris"
|
||||
assert agent.role == "research assistant"
|
||||
assert agent.debug_conversation_id is not None
|
||||
# A v1 snapshot + revision are seeded and wired as the active version.
|
||||
snapshots = [a for a in session.added if isinstance(a, AgentConfigSnapshot)]
|
||||
assert len(snapshots) == 1
|
||||
@@ -1460,12 +1459,18 @@ class TestAgentAppBackingAgent:
|
||||
assert len(revisions) == 1
|
||||
conversations = [a for a in session.added if isinstance(a, Conversation)]
|
||||
assert len(conversations) == 1
|
||||
assert agent.debug_conversation_id == conversations[0].id
|
||||
assert conversations[0].app_id == "app-1"
|
||||
assert conversations[0].mode == "agent"
|
||||
assert conversations[0].status == ConversationStatus.NORMAL
|
||||
assert conversations[0].from_source == ConversationFromSource.CONSOLE
|
||||
assert conversations[0].from_account_id == "account-1"
|
||||
debug_mappings = [a for a in session.added if isinstance(a, AgentDebugConversation)]
|
||||
assert len(debug_mappings) == 1
|
||||
assert debug_mappings[0].tenant_id == "tenant-1"
|
||||
assert debug_mappings[0].agent_id == agent.id
|
||||
assert debug_mappings[0].app_id == "app-1"
|
||||
assert debug_mappings[0].account_id == "account-1"
|
||||
assert debug_mappings[0].conversation_id == conversations[0].id
|
||||
# Caller (AppService.create_app) owns the commit — helper must not commit.
|
||||
assert session.commits == 0
|
||||
|
||||
@@ -2436,18 +2441,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)
|
||||
@@ -2467,47 +2471,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=[])
|
||||
@@ -2524,149 +2528,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 ────────────────────
|
||||
@@ -2704,58 +2573,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",
|
||||
@@ -2793,26 +2611,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",
|
||||
@@ -2850,29 +2669,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"
|
||||
|
||||
@@ -42,6 +42,19 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
|
||||
]
|
||||
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(
|
||||
@@ -68,17 +81,15 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
|
||||
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"]
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -477,6 +665,68 @@ def test_preview_unknown_key_is_404():
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
def test_inspect_skill_uses_skill_metadata_manifest_files():
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="pdf-toolkit/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(
|
||||
name="PDF Toolkit",
|
||||
description="Reads PDFs.",
|
||||
manifest_files=["SKILL.md", "scripts/extract.py", "references/guide.md"],
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
storage_mock.load_stream.return_value = iter([b"---\nname: PDF Toolkit\n---\nUse it.\n"])
|
||||
result = AgentDriveService().inspect_skill(tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit")
|
||||
|
||||
assert result["source"] == "skill_md"
|
||||
assert result["name"] == "PDF Toolkit"
|
||||
assert [file["path"] for file in result["files"]] == [
|
||||
"SKILL.md",
|
||||
"references/guide.md",
|
||||
"scripts/extract.py",
|
||||
]
|
||||
assert result["files"][0]["drive_key"] == "pdf-toolkit/SKILL.md"
|
||||
assert result["files"][1]["available_in_drive"] is False
|
||||
assert result["file_tree"][0]["name"] == "references"
|
||||
assert result["skill_md"]["text"].startswith("---\nname: PDF Toolkit")
|
||||
assert result["warnings"] == []
|
||||
|
||||
|
||||
def test_inspect_skill_falls_back_to_drive_keys_without_manifest_files():
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="pdf-toolkit/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="PDF Toolkit", description="Reads PDFs."),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
|
||||
result = AgentDriveService().inspect_skill(tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit")
|
||||
|
||||
assert [file["path"] for file in result["files"]] == ["SKILL.md"]
|
||||
assert result["warnings"] == ["manifest_files_unavailable"]
|
||||
|
||||
|
||||
def test_preview_rejects_cross_tenant_agent():
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().preview(
|
||||
@@ -502,7 +752,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")
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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" },
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+4
-4
@@ -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]]
|
||||
|
||||
@@ -189,7 +189,6 @@ export type MessageFeedbackPayload = {
|
||||
}
|
||||
|
||||
export type AgentDriveDeleteResponse = {
|
||||
config_version_id?: string | null
|
||||
removed_keys?: Array<string>
|
||||
result: string
|
||||
}
|
||||
@@ -199,7 +198,6 @@ export type AgentDriveFilePayload = {
|
||||
}
|
||||
|
||||
export type AgentDriveFileCommitResponse = {
|
||||
config_version_id?: string | null
|
||||
file: AgentDriveFileResponse
|
||||
}
|
||||
|
||||
@@ -283,7 +281,7 @@ export type SandboxUploadResponse = {
|
||||
|
||||
export type AgentSkillUploadResponse = {
|
||||
manifest: SkillManifest
|
||||
skill: AgentSkillRefConfig
|
||||
skill: AgentUploadedSkillResponse
|
||||
}
|
||||
|
||||
export type SkillToolInferenceResult = {
|
||||
@@ -479,7 +477,6 @@ export type AgentSoulConfig = {
|
||||
prompt?: AgentSoulPromptConfig
|
||||
sandbox?: AgentSoulSandboxConfig
|
||||
schema_version?: number
|
||||
skills_files?: AgentSoulSkillsFilesConfig
|
||||
tools?: AgentSoulToolsConfig
|
||||
}
|
||||
|
||||
@@ -529,14 +526,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 = {
|
||||
@@ -775,18 +764,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 = {
|
||||
@@ -933,11 +916,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>
|
||||
@@ -1060,37 +1038,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
|
||||
@@ -1285,21 +1232,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'
|
||||
@@ -1368,6 +1300,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'
|
||||
|
||||
@@ -47,7 +47,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(),
|
||||
})
|
||||
@@ -404,7 +403,6 @@ export const zAgentDriveFileResponse = z.object({
|
||||
* AgentDriveFileCommitResponse
|
||||
*/
|
||||
export const zAgentDriveFileCommitResponse = z.object({
|
||||
config_version_id: z.string().nullish(),
|
||||
file: zAgentDriveFileResponse,
|
||||
})
|
||||
|
||||
@@ -610,19 +608,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(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -630,7 +623,7 @@ export const zAgentSkillRefConfig = z.object({
|
||||
*/
|
||||
export const zAgentSkillUploadResponse = z.object({
|
||||
manifest: zSkillManifest,
|
||||
skill: zAgentSkillRefConfig,
|
||||
skill: zAgentUploadedSkillResponse,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -985,41 +978,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
|
||||
*/
|
||||
@@ -1340,39 +1298,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
|
||||
*
|
||||
@@ -1416,6 +1341,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
|
||||
*
|
||||
@@ -1526,22 +1476,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(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1761,7 +1695,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',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user