Compare commits

...
Author SHA1 Message Date
Yansong Zhang 90fbe990c0 fix: isolate agent debug conversations by account 2026-06-22 21:03:22 +08:00
Yanli 盐粒 f72b31b346 Always include agent drive layer 2026-06-22 20:07:12 +08:00
Yanli 盐粒 ebc589915b Merge branch 'feat/agent-v2' into deploy/agent
# Conflicts:
#	api/services/agent/composer_service.py
2026-06-22 19:12:22 +08:00
Yanli 盐粒 8e89d640b0 Bind shell layer to drive when configured 2026-06-22 19:06:32 +08:00
Joel ab76142a65 chore: advanced setting defalut fold 2026-06-22 18:39:14 +08:00
yyh 3d8bdc54ff Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-22 18:36:53 +08:00
Joel c8d2a0febf fix: not related app agent not show panel 2026-06-22 18:32:01 +08:00
Yanli 盐粒 8ad19ac84e Merge remote-tracking branch 'origin/deploy/agent' into deploy/agent 2026-06-22 18:24:35 +08:00
yyh 9573e1c842 fix: invalidate reference workflow queries when publish workflow 2026-06-22 18:23:30 +08:00
Yanli 盐粒 5469f2c930 Merge branch 'feat/agent-v2' into deploy/agent 2026-06-22 18:20:02 +08:00
Yansong Zhang e548fe0460 feat(agent): add roster service api access 2026-06-22 18:09:00 +08:00
yyh 185903fad1 fix: state management about publish 2026-06-22 18:08:05 +08:00
yyh 08bde4842d fix: collapse toolbar after publish 2026-06-22 18:08:05 +08:00
Yanli 盐粒 049f104390 Align drive commit protocol test with optional file refs 2026-06-22 18:05:28 +08:00
Yanli 盐粒 07623d299d Remove drive base from drive layer 2026-06-22 18:05:28 +08:00
yyh c30b39cd2e fix: no loading 2026-06-22 17:50:32 +08:00
yyh c425603e6f feat: publish toolbar 2026-06-22 17:46:28 +08:00
yyh 4cb1666252 fix: toolbar issue 2026-06-22 17:46:28 +08:00
Yanli 盐粒 f507c65397 Merge remote-tracking branch 'origin/deploy/agent' into deploy/agent 2026-06-22 17:37:39 +08:00
Yanli 盐粒 f1fa71c15e Fix agent drive ToolFile user canonicalization 2026-06-22 17:36:39 +08:00
Yanli 盐粒 d0e6b28c8f Merge branch 'feat/agent-v2' into deploy/agent 2026-06-22 17:36:16 +08:00
Yanli 盐粒 1f33f46a0f Fix agent drive ToolFile user canonicalization 2026-06-22 17:35:25 +08:00
Yansong Zhang 89049c1714 fix(agent): switch roster node to inline 2026-06-22 17:03:58 +08:00
autofix-ci[bot]andGitHub cc71c3a2c3 [autofix.ci] apply automated fixes 2026-06-22 08:52:50 +00:00
Yanli 盐粒 76f3b757ef Merge remote-tracking branch 'origin/feat/agent-v2' into deploy/agent
# Conflicts:
#	api/controllers/console/app/agent_drive_inspector.py
#	api/services/agent_drive_service.py
#	api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py
2026-06-22 16:52:16 +08:00
Yanli 盐粒 11084104f4 Fix agent stub server import cycle 2026-06-22 16:45:54 +08:00
Yanli 盐粒 e3aee7be71 Rename Dify agent inner API settings 2026-06-22 16:45:54 +08:00
Yanli 盐粒 e4bab0f26f Merge remote-tracking branch 'origin/feat/agent-v2' into feat/agent-v2 2026-06-22 16:44:46 +08:00
Yanli 盐粒 c3bd42ab93 Merge remote-tracking branch 'origin/main' into feat/agent-v2
# Conflicts:
#	api/controllers/console/app/agent_drive_inspector.py
#	api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py
#	api/openapi/markdown/console-openapi.md
#	api/services/agent/skill_standardize_service.py
#	api/services/agent_drive_service.py
#	api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py
#	api/tests/unit_tests/services/agent/test_skill_standardize_service.py
#	packages/contracts/generated/api/console/agent/orpc.gen.ts
#	packages/contracts/generated/api/console/agent/types.gen.ts
#	packages/contracts/generated/api/console/agent/zod.gen.ts
#	packages/contracts/generated/api/console/apps/orpc.gen.ts
#	packages/contracts/generated/api/console/apps/types.gen.ts
#	packages/contracts/generated/api/console/apps/zod.gen.ts
2026-06-22 16:43:54 +08:00
yyh e7bd444044 fix(web): preserve agent dialog opening animation 2026-06-22 16:27:44 +08:00
Yanli 盐粒 e6eedd6044 Fix agent stub server import cycle 2026-06-22 16:25:03 +08:00
yyh 0262fff48d feat: use disablePointerDismissal prop with form dialog 2026-06-22 15:59:27 +08:00
Yansong Zhang 9eceda40b1 fix(agent): add stable debug conversation 2026-06-22 15:40:49 +08:00
yyh 76af36abb2 feat: duplicate agent use dialog form 2026-06-22 15:39:26 +08:00
Joel 42772a27f6 chore: inline node title 2026-06-22 15:36:51 +08:00
yyh 2aea989971 fix(agent-v2): align monitoring source filter styling 2026-06-22 14:40:04 +08:00
yyh 14a8f1870b feat(agent-v2): connect monitoring metrics to backend 2026-06-22 14:33:46 +08:00
Yanli 盐粒 c76461ea56 Merge Dify agent inner API settings into deploy agent 2026-06-22 14:33:12 +08:00
Yanli 盐粒 09b9efced2 Rename Dify agent inner API settings 2026-06-22 14:25:21 +08:00
yyh f2a3b74321 fix: improve chip accessibility 2026-06-22 13:47:49 +08:00
yyh b742ff7335 fix: improve sort focus accessibility 2026-06-22 13:41:07 +08:00
yyh ac20089dfa fix: keep previous data when filter 2026-06-22 13:29:48 +08:00
yyh c2541f36cb fix: sort components style 2026-06-22 13:29:06 +08:00
yyh be43316404 feat: agent roster logs 2026-06-22 13:26:35 +08:00
Yansong Zhang 7a2ee67a5a fix(agent): support restoring roster versions 2026-06-22 13:14:44 +08:00
autofix-ci[bot]andGitHub 5b22648ec0 [autofix.ci] apply automated fixes 2026-06-22 05:12:15 +00:00
Yanli 盐粒 1c5839e351 Fix agent v2 CI failures 2026-06-22 13:03:37 +08:00
yyh 9840dd1e2b Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-22 12:24:25 +08:00
Joel f8309f3311 feat: add version switch and fix item hover 2026-06-22 11:34:03 +08:00
Yansong Zhang 57bae9e69f feat(agent): add skill inspect API 2026-06-22 11:29:15 +08:00
Joel 9fcb267dfe chore: files skills remove call api and modal problem 2026-06-22 10:45:02 +08:00
Yansong Zhang 9f313d4a05 fix(agent): return conflict for duplicate app agent names 2026-06-20 16:58:02 +08:00
盐粒 Yanli 8d2a0269fe Merge commit '07b930f77cd721893c23e713110756581b1ba675' into deploy-agent 2026-06-19 21:57:40 +08:00
盐粒 Yanli 07b930f77c fix(agent-v2): refresh api shellctl lock 2026-06-19 21:56:40 +08:00
盐粒 Yanli d285d64d8b Merge remote-tracking branch 'origin/feat/agent-v2' into deploy-agent 2026-06-19 20:45:24 +08:00
盐粒 Yanli bed0e59c2e Merge branch 'local-agent-dockerfile-stub-shellctl' into feat/agent-v2 2026-06-19 20:43:24 +08:00
盐粒 Yanli 2ef2061b26 Merge branch 'local-agent-dockerfile-stub-shellctl' into deploy/agent 2026-06-19 20:29:31 +08:00
盐粒 Yanli da2e3e71b6 fix(agent-v2): upgrade shellctl to 2.2.1 2026-06-19 20:26:52 +08:00
盐粒 Yanli 5f876135f7 revert(agent-v2): remove local sandbox precompile workaround 2026-06-19 19:19:56 +08:00
盐粒 Yanli 0ca0229a9e Merge branch 'local-agent-dockerfile-stub-shellctl' into deploy/agent 2026-06-19 18:18:20 +08:00
盐粒 Yanli 3653bab490 ci(agent-v2): isolate local sandbox digests 2026-06-19 18:18:20 +08:00
盐粒 Yanli bb469e17ca Merge branch 'local-agent-dockerfile-stub-shellctl' into deploy/agent 2026-06-19 18:10:52 +08:00
盐粒 Yanli ed4f3b213c ci(agent-v2): publish local sandbox image 2026-06-19 18:10:38 +08:00
盐粒 Yanli cc6f379900 Merge branch 'local-agent-dockerfile-stub-shellctl' into deploy/agent 2026-06-19 17:53:00 +08:00
盐粒 Yanli 590dd67501 fix(agent-v2): precompile local sandbox packages 2026-06-19 17:52:46 +08:00
盐粒 Yanli 48ff55236b Merge branch 'local-agent-dockerfile-stub-shellctl' into deploy/agent 2026-06-19 16:18:16 +08:00
盐粒 Yanli df3ac41d29 feat(agent-v2): add local sandbox image 2026-06-19 16:17:06 +08:00
yyh 4608bde818 fix: style 2026-06-19 15:07:09 +08:00
yyh 137d618870 fix(agent-v2): reset roster edit form on reopen 2026-06-19 15:06:33 +08:00
yyh 53e088cc32 fix(agent-v2): route selector actions through combobox items 2026-06-19 14:24:01 +08:00
盐粒 Yanli 1dafe37da8 fix(agent-v2): address CI after main merge 2026-06-19 11:50:52 +08:00
盐粒 Yanli 13d9fd7826 Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-19 11:33:34 +08:00
盐粒 Yanli 4a083649b5 fix(migrations): tolerate existing tenant last opened column 2026-06-19 11:18:53 +08:00
盐粒 Yanli 6ebf5f0c74 fix(agent-v2): linearize drive metadata migration 2026-06-19 11:01:24 +08:00
盐粒 Yanli fcad148ff3 fix(agent-v2): break drive service import cycle 2026-06-19 03:26:48 +08:00
盐粒 Yanli df9006f01b feat(agent-v2): derive skills from agent drive 2026-06-19 02:56:16 +08:00
196 changed files with 7449 additions and 3280 deletions
+21
View File
@@ -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
+17 -6
View File
@@ -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(),
)
+160 -12
View File
@@ -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")
+31 -58
View File
@@ -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}
+47 -4
View File
@@ -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,
+23 -10
View File
@@ -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,
)
+45 -2
View File
@@ -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 -18
View File
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Annotated, Literal
from typing import Literal
from pydantic import Field, field_validator
@@ -16,10 +16,8 @@ from models.agent import (
)
from models.agent_config_entities import (
AgentCliToolConfig,
AgentFileRefConfig,
AgentHumanContactConfig,
AgentKnowledgeDatasetConfig,
AgentSkillRefConfig,
AgentSoulConfig,
DeclaredOutputConfig,
DeclaredOutputType,
@@ -396,20 +394,6 @@ class AgentComposerDifyToolCandidateResponse(ResponseModel):
tools_count: int | None = None
class AgentComposerSkillCandidateResponse(AgentSkillRefConfig):
kind: Literal["skill"] = "skill"
class AgentComposerFileCandidateResponse(AgentFileRefConfig):
kind: Literal["file"] = "file"
AgentComposerSkillFileCandidateResponse = Annotated[
AgentComposerSkillCandidateResponse | AgentComposerFileCandidateResponse,
Field(discriminator="kind"),
]
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
previous_node_outputs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list)
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
@@ -417,7 +401,6 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
class AgentComposerSoulCandidatesResponse(ResponseModel):
skills_files: list[AgentComposerSkillFileCandidateResponse] = Field(default_factory=list)
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
@@ -7,7 +7,7 @@ Create Date: 2026-06-05 11:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from alembic import context, op
# revision identifiers, used by Alembic.
revision = "b7c2d9e8a1f4"
@@ -17,10 +17,23 @@ depends_on = None
def upgrade():
if _has_last_opened_at_column():
return
with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op:
batch_op.add_column(sa.Column("last_opened_at", sa.DateTime(), nullable=True))
def downgrade():
if not _has_last_opened_at_column():
return
with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op:
batch_op.drop_column("last_opened_at")
def _has_last_opened_at_column() -> bool:
if context.is_offline_mode():
# Offline SQL generation cannot inspect the target schema. Assume the
# linear migration path so generated SQL stays explicit.
return False
inspector = sa.inspect(op.get_bind())
return "last_opened_at" in {column["name"] for column in inspector.get_columns("tenant_account_joins")}
@@ -6,9 +6,15 @@ Create Date: 2026-06-18 23:00:00.000000
"""
import sqlalchemy as sa
from __future__ import annotations
import json
from typing import Any
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy.engine.mock import MockConnection
# revision identifiers, used by Alembic.
revision = "b2515f9d4c2a"
@@ -31,9 +37,46 @@ def upgrade() -> None:
"agent_drive_files",
["tenant_id", "agent_id", "is_skill", "key"],
)
_remove_skills_files_from_snapshots()
def downgrade() -> None:
op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files")
op.drop_column("agent_drive_files", "skill_metadata")
op.drop_column("agent_drive_files", "is_skill")
def _remove_skills_files_from_snapshots() -> None:
connection = op.get_bind()
if connection is None or isinstance(connection, MockConnection):
return
snapshots = sa.table(
"agent_config_snapshots",
sa.column("id", sa.String()),
sa.column("config_snapshot", sa.Text()),
)
rows = connection.execute(sa.select(snapshots.c.id, snapshots.c.config_snapshot)).fetchall()
for row in rows:
cleaned = _strip_skills_files(row.config_snapshot)
if cleaned is None:
continue
connection.execute(
snapshots.update()
.where(snapshots.c.id == row.id)
.values(config_snapshot=json.dumps(cleaned, separators=(",", ":"), sort_keys=True))
)
def _strip_skills_files(raw_snapshot: Any) -> dict[str, Any] | None:
if raw_snapshot is None:
return None
if isinstance(raw_snapshot, str):
snapshot = json.loads(raw_snapshot)
elif isinstance(raw_snapshot, dict):
snapshot = dict(raw_snapshot)
else:
snapshot = dict(raw_snapshot)
if not isinstance(snapshot, dict) or "skills_files" not in snapshot:
return None
snapshot.pop("skills_files", None)
return snapshot
@@ -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")
+2
View File
@@ -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
View File
@@ -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.
-6
View File
@@ -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)
+13 -62
View File
@@ -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,
+34 -210
View File
@@ -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
+2
View File
@@ -191,6 +191,8 @@ class ComposerConfigValidator:
}
)
continue
if mention.kind in {MentionKind.SKILL, MentionKind.FILE}:
continue
if resolved is None:
warnings.append(
{
+23 -25
View File
@@ -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",
+96 -6
View File
@@ -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 -21
View File
@@ -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."""
+17 -23
View File
@@ -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:
+69 -6
View File
@@ -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
@@ -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
View File
@@ -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"]
-25
View File
@@ -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
+8 -4
View File
@@ -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`.
+1 -1
View File
@@ -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",
]
+308 -14
View File
@@ -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,
),
),
)
+7 -7
View File
@@ -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,
+33 -36
View File
@@ -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=[
+17 -4
View File
@@ -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
+4 -4
View File
@@ -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