This commit is contained in:
盐粒 Yanli 2026-06-26 00:48:39 +08:00 committed by GitHub
commit ec0fc0cc2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
302 changed files with 18367 additions and 4332 deletions

View File

@ -78,11 +78,22 @@ 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
def _shell_layer_deps() -> dict[str, str]:
return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
def _drive_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _shell_config_with_drive_ref(
shell_config: DifyShellLayerConfig | None,
drive_config: DifyDriveLayerConfig | None,
) -> DifyShellLayerConfig:
config = shell_config or DifyShellLayerConfig()
if drive_config is None:
return config
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
class AgentBackendModelConfig(BaseModel):
@ -263,14 +274,29 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = run_input.include_shell or run_input.drive_config is not None
if include_shell:
# Sandboxed bash workspace (dify.shell). It enters before drive so
# drive can materialize mentioned targets with `dify-agent drive pull`
# in the same shell-visible filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): a config-only index;
# the agent pulls listed entries through the back proxy by drive_ref.
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
deps=_drive_layer_deps(),
metadata=run_input.metadata,
config=run_input.drive_config,
)
@ -312,7 +338,7 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
if run_input.knowledge is not None and run_input.knowledge.sets:
layers.append(
RunLayerSpec(
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
@ -336,21 +362,6 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). Depends on execution_context
# so the agent server can mint per-command Agent Stub env, and on
# drive when present so that env points at /mnt/drive/<drive_ref>.
# shellctl connection itself is server-injected.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
metadata=run_input.metadata,
config=run_input.shell_config or DifyShellLayerConfig(),
)
)
if run_input.output is not None:
layers.append(
RunLayerSpec(
@ -462,14 +473,29 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = run_input.include_shell or run_input.drive_config is not None
if include_shell:
# Sandboxed bash workspace (dify.shell). It enters before drive so
# drive can materialize mentioned targets with `dify-agent drive pull`
# in the same shell-visible filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): a config-only index;
# the agent pulls listed entries through the back proxy by drive_ref.
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
deps=_drive_layer_deps(),
metadata=run_input.metadata,
config=run_input.drive_config,
)
@ -513,7 +539,7 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
if run_input.knowledge is not None and run_input.knowledge.sets:
layers.append(
RunLayerSpec(
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
@ -537,21 +563,6 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). Depends on execution_context
# so the agent server can mint per-command Agent Stub env, and on
# drive when present so that env points at /mnt/drive/<drive_ref>.
# shellctl connection itself is server-injected.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
metadata=run_input.metadata,
config=run_input.shell_config or DifyShellLayerConfig(),
)
)
if run_input.output is not None:
layers.append(
RunLayerSpec(

View File

@ -36,8 +36,8 @@ class AgentBackendConfig(BaseSettings):
description=(
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
"into Agent runs. The declaration is an index only — the agent backend "
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
"until the agent backend registers the dify.drive layer type."
"pulls the actual SKILL.md / files through the back proxy. Set this to "
"false only when temporarily rolling back the drive integration."
),
default=False,
default=True,
)

View File

@ -6,5 +6,15 @@ from services.agent.roster_service import AgentRosterService
def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App:
"""Resolve the hidden Agent App backing an Agent Console resource."""
"""Resolve a roster Agent's public Agent App."""
return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
def resolve_agent_runtime_app_model(*, tenant_id: str, agent_id: UUID) -> App:
"""Resolve the App that backs an Agent runtime surface.
This accepts both roster Agent Apps and workflow-only inline Agents with a
hidden backing App.
"""
return AgentRosterService(db.session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))

View File

@ -1,10 +1,10 @@
from uuid import UUID
from flask import request
from flask_restx import Resource
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@ -28,9 +28,15 @@ from libs.login import login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload
from services.entities.agent_entities import (
ComposerSavePayload,
WorkflowAgentComposerQuery,
WorkflowComposerCopyFromRosterPayload,
)
register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload)
register_schema_models(
console_ns, ComposerSavePayload, WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload
)
register_response_schema_models(
console_ns,
AgentAppComposerResponse,
@ -41,27 +47,26 @@ register_response_schema_models(
)
def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str:
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
class WorkflowAgentComposerApi(Resource):
@console_ns.response(
200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@console_ns.doc(params=query_params_from_model(WorkflowAgentComposerQuery))
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, node_id: str):
query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True))
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
snapshot_id=query.snapshot_id,
),
)
@ -137,6 +142,7 @@ class WorkflowAgentComposerValidateApi(Resource):
def post(self, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_publish_payload(payload)
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
@ -228,10 +234,9 @@ class AgentComposerApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(
AgentAppComposerResponse,
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id),
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)),
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@ -244,13 +249,12 @@ class AgentComposerApi(Resource):
@with_current_user_id
@with_current_tenant_id
def put(self, tenant_id: str, account_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
AgentAppComposerResponse,
AgentComposerService.save_agent_app_composer(
AgentComposerService.save_agent_composer(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
account_id=account_id,
payload=payload,
),
@ -268,9 +272,10 @@ class AgentComposerValidateApi(Resource):
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id))
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_publish_payload(payload)
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
@ -290,12 +295,11 @@ class AgentComposerCandidatesApi(Resource):
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
user_id=current_user_id,
),
)

View File

@ -7,7 +7,7 @@ from sqlalchemy import func, select
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_app_model, resolve_agent_runtime_app_model
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
from controllers.console.app.app import (
AppDetailWithSite as GenericAppDetailWithSite,
@ -54,8 +54,10 @@ from libs.datetime_utils import parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.agent import Agent, AgentStatus
from models.enums import ApiTokenType
from models.model import ApiToken, App, IconType
from services.agent.composer_service import AgentComposerService
from services.agent.errors import AgentNotFoundError
from services.agent.observability_service import (
AgentLogQueryParams,
@ -65,7 +67,7 @@ from services.agent.observability_service import (
from services.agent.roster_service import AgentRosterService
from services.app_service import AppListParams, AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import RosterListQuery
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
from services.feature_service import FeatureService
@ -232,6 +234,8 @@ class AgentStatisticsQuery(BaseModel):
class AgentAppPartial(GenericAppPartial):
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
debug_conversation_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
@ -241,6 +245,8 @@ class AgentAppPartial(GenericAppPartial):
class AgentAppDetailWithSite(GenericAppDetailWithSite):
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
debug_conversation_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
@ -250,6 +256,36 @@ class AgentDebugConversationRefreshResponse(BaseModel):
debug_conversation_id: str
class AgentPublishPayload(BaseModel):
version_note: str | None = Field(default=None, description="Optional note for this published Agent version")
class AgentPublishResponse(BaseModel):
result: str
active_config_snapshot_id: str
active_config_snapshot: dict[str, object] | None = None
draft: dict[str, object] | None = None
class AgentBuildDraftCheckoutPayload(BaseModel):
force: bool = Field(default=False, description="Overwrite the existing current-user build draft")
class AgentBuildDraftResponse(BaseModel):
variant: str
draft: dict[str, object]
agent_soul: dict[str, object]
class AgentBuildDraftApplyResponse(BaseModel):
result: str
draft: dict[str, object]
class AgentSimpleResultResponse(BaseModel):
result: str
class AgentAppPagination(GenericAppPagination):
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
validation_alias=AliasChoices("items", "data")
@ -261,6 +297,9 @@ register_schema_models(
AgentAppCreatePayload,
AgentAppUpdatePayload,
AgentAppCopyPayload,
AgentPublishPayload,
AgentBuildDraftCheckoutPayload,
ComposerSavePayload,
AgentApiStatusPayload,
AgentInviteOptionsQuery,
AgentLogsQuery,
@ -277,6 +316,10 @@ register_response_schema_models(
AgentAppDetailWithSite,
AgentAppPartial,
AgentDebugConversationRefreshResponse,
AgentPublishResponse,
AgentBuildDraftResponse,
AgentBuildDraftApplyResponse,
AgentSimpleResultResponse,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentConfigSnapshotRestoreResponse,
@ -294,7 +337,7 @@ def _agent_roster_service() -> AgentRosterService:
return AgentRosterService(db.session)
def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: str | None = None) -> dict:
"""Serialize an Agent App detail using roster-only DTOs.
`/agent` responses are roster-shaped rather than raw app-shaped: `id`
@ -311,11 +354,23 @@ def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
roster_service = _agent_roster_service()
payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
agent = (
db.session.scalar(
select(Agent).where(
Agent.tenant_id == app_model.tenant_id,
Agent.id == agent_id,
Agent.status == AgentStatus.ACTIVE,
)
)
if agent_id
else roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
)
if not agent:
raise AgentNotFoundError()
payload.pop("bound_agent_id", None)
payload["app_id"] = str(app_model.id)
payload["app_id"] = agent.app_id
payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent)
payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id)
payload["id"] = agent.id
payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id(
tenant_id=app_model.tenant_id,
@ -365,6 +420,8 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_u
agent = agents_by_app_id.get(app_id)
if agent:
item["app_id"] = app_id
item["backing_app_id"] = agent.backing_app_id or app_id
item["hidden_app_backed"] = False
item["id"] = agent.id
item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id)
item["role"] = agent.role or ""
@ -516,8 +573,8 @@ class AgentAppApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _serialize_agent_app_detail(app_model, current_user=current_user)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _serialize_agent_app_detail(app_model, current_user=current_user, agent_id=str(agent_id))
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@ -583,6 +640,112 @@ class AgentDebugConversationRefreshApi(Resource):
)
@console_ns.route("/agent/<uuid:agent_id>/publish")
class AgentPublishApi(Resource):
@console_ns.expect(console_ns.models[AgentPublishPayload.__name__])
@console_ns.response(200, "Agent draft published", console_ns.models[AgentPublishResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
args = AgentPublishPayload.model_validate(console_ns.payload or {})
return AgentComposerService.publish_agent_app_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
version_note=args.version_note,
)
@console_ns.route("/agent/<uuid:agent_id>/build-draft/checkout")
class AgentBuildDraftCheckoutApi(Resource):
@console_ns.expect(console_ns.models[AgentBuildDraftCheckoutPayload.__name__])
@console_ns.response(200, "Agent build draft checked out", console_ns.models[AgentBuildDraftResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {})
return AgentComposerService.checkout_agent_app_build_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
force=args.force,
)
@console_ns.route("/agent/<uuid:agent_id>/build-draft")
class AgentBuildDraftApi(Resource):
@console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
return AgentComposerService.load_agent_app_build_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(200, "Agent build draft saved", console_ns.models[AgentBuildDraftResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def put(self, tenant_id: str, current_user: Account, agent_id: UUID):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_agent_app_build_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
payload=payload,
)
@console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
return AgentComposerService.discard_agent_app_build_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
)
@console_ns.route("/agent/<uuid:agent_id>/build-draft/apply")
class AgentBuildDraftApplyApi(Resource):
@console_ns.response(200, "Agent build draft applied", console_ns.models[AgentBuildDraftApplyResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
return AgentComposerService.apply_agent_app_build_draft(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
)
@console_ns.route("/agent/<uuid:agent_id>/copy")
class AgentAppCopyApi(Resource):
@console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__])
@ -712,7 +875,7 @@ class AgentLogsApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
query_data["sources"] = _multi_query_values("sources", "source")
query_data["statuses"] = _multi_query_values("statuses", "status")
@ -749,7 +912,7 @@ class AgentLogMessagesApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
query_data["sources"] = _multi_query_values("sources", "source")
query_data["statuses"] = _multi_query_values("statuses", "status")
@ -786,7 +949,7 @@ class AgentLogSourcesApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id))
return dump_response(AgentLogSourceListResponse, payload)
@ -805,7 +968,7 @@ class AgentStatisticsSummaryApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True))
timezone = current_user.timezone or "UTC"
start, end = _parse_observability_time_range(query.start, query.end, current_user)

View File

@ -13,7 +13,7 @@ from controllers.common.schema import (
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@ -351,7 +351,7 @@ class AgentSkillUploadByAgentApi(Resource):
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
@ -394,7 +394,7 @@ class AgentDriveFilesByAgentApi(Resource):
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
@console_ns.doc("delete_agent_drive_file_by_agent")
@ -407,7 +407,7 @@ class AgentDriveFilesByAgentApi(Resource):
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
@ -454,7 +454,7 @@ class AgentSkillByAgentApi(Resource):
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False)
@ -494,7 +494,7 @@ class AgentSkillInferToolsByAgentApi(Resource):
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID, slug: str):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)

View File

@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
@ -87,7 +87,7 @@ class AgentAppFeatureConfigResource(Resource):
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
new_app_model_config = AgentAppFeatureConfigService.update_features(

View File

@ -22,7 +22,7 @@ from controllers.common.schema import (
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
@ -144,7 +144,7 @@ class AgentAppSandboxListResource(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxListQuery)
try:
result = AgentAppSandboxService().list_files(
@ -169,7 +169,7 @@ class AgentAppSandboxReadResource(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxFileQuery)
try:
result = AgentAppSandboxService().read_file(
@ -194,7 +194,7 @@ class AgentAppSandboxUploadResource(Resource):
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
try:
result = AgentAppSandboxService().upload_file(

View File

@ -25,7 +25,7 @@ from controllers.common.schema import (
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
@ -182,7 +182,7 @@ class AgentDriveListByAgentApi(Resource):
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveListByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
except AgentDriveError as exc:
@ -201,7 +201,7 @@ class AgentDriveSkillListByAgentApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
except AgentDriveError as exc:
@ -220,7 +220,7 @@ class AgentDriveSkillInspectByAgentApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID, skill_path: str):
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
return _json_response(
AgentDriveService().inspect_skill(
@ -245,7 +245,7 @@ class AgentDrivePreviewByAgentApi(Resource):
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
@ -264,7 +264,7 @@ class AgentDriveDownloadByAgentApi(Resource):
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:

View File

@ -331,7 +331,7 @@ class ModelConfig(ResponseModel):
return to_timestamp(value)
class Site(ResponseModel):
class AppDetailSiteResponse(ResponseModel):
access_token: str | None = Field(default=None, validation_alias="code")
code: str | None = None
title: str | None = None
@ -461,7 +461,7 @@ class AppDetailWithSite(AppDetail):
api_base_url: str | None = None
max_active_requests: int | None = None
deleted_tools: list[DeletedTool] = Field(default_factory=list)
site: Site | None = None
site: AppDetailSiteResponse | None = None
# For Agent App type: the roster Agent backing this app (None otherwise).
bound_agent_id: str | None = None
# For Agent App responses exposed through /agent.
@ -546,7 +546,7 @@ register_schema_models(
WorkflowPartial,
ModelConfigPartial,
ModelConfig,
Site,
AppDetailSiteResponse,
DeletedTool,
AppDetail,
AppExportResponse,

View File

@ -11,7 +11,7 @@ import services
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import (
AppUnavailableError,
CompletionRequestError,
@ -93,6 +93,10 @@ class ChatMessagePayload(BaseMessagePayload):
query: str = Field(..., description="User query")
conversation_id: str | None = Field(default=None, description="Conversation ID")
parent_message_id: str | None = Field(default=None, description="Parent message ID")
draft_type: Literal["draft", "debug_build"] = Field(
default="draft",
description="Agent App debug config source. Use debug_build while the Agent is in build mode.",
)
@field_validator("conversation_id", "parent_message_id")
@classmethod
@ -218,7 +222,7 @@ class AgentChatMessageApi(Resource):
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _create_chat_message(
current_tenant_id=current_tenant_id,
current_user=current_user,
@ -254,7 +258,7 @@ class AgentChatMessageStopApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)

View File

@ -13,7 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes
from controllers.common.fields import SimpleResultResponse, TextFileResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import (
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
@ -210,7 +210,7 @@ class AgentChatMessageListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _list_chat_messages(app_model=app_model, current_user=current_user)
@ -246,7 +246,7 @@ class AgentMessageFeedbackApi(Resource):
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _update_message_feedback(current_user=current_user, app_model=app_model)
@ -311,7 +311,7 @@ class AgentMessageSuggestedQuestionApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
@ -389,7 +389,7 @@ class AgentMessageApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _get_message_detail(app_model=app_model, message_id=message_id)

View File

@ -14,11 +14,12 @@ api = ExternalApi(
files_ns = Namespace("files", description="File operations", path="/")
from . import image_preview, tool_files, upload
from . import agent_drive_archive, image_preview, tool_files, upload
api.add_namespace(files_ns)
__all__ = [
"agent_drive_archive",
"api",
"bp",
"files_ns",

View File

@ -0,0 +1,67 @@
from urllib.parse import quote
from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from controllers.common.file_response import enforce_download_for_html
from controllers.common.schema import register_schema_models
from controllers.files import files_ns
from models.agent import AgentDriveFileKind
from services.agent_drive_service import AgentDriveError, AgentDriveService
class AgentDriveArchiveMemberQuery(BaseModel):
tenant_id: str = Field(..., description="Tenant ID")
agent_id: str = Field(..., description="Agent ID")
key: str = Field(..., description="Virtual drive key")
archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind")
archive_file_id: str = Field(..., description="Archive file id")
member_path: str = Field(..., description="Zip member path")
timestamp: str = Field(..., description="Unix timestamp")
nonce: str = Field(..., description="Random nonce")
sign: str = Field(..., description="HMAC signature")
as_attachment: bool = Field(default=False, description="Download as attachment")
register_schema_models(files_ns, AgentDriveArchiveMemberQuery)
@files_ns.route("/agent-drive/archive-member")
class AgentDriveArchiveMemberApi(Resource):
@files_ns.doc("get_agent_drive_archive_member")
@files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters")
def get(self):
args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True))
if not AgentDriveService.verify_archive_member_signature(
tenant_id=args.tenant_id,
agent_id=args.agent_id,
key=args.key,
archive_file_kind=args.archive_file_kind,
archive_file_id=args.archive_file_id,
member_path=args.member_path,
timestamp=args.timestamp,
nonce=args.nonce,
sign=args.sign,
):
raise Forbidden("Invalid request.")
try:
payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request(
tenant_id=args.tenant_id,
agent_id=args.agent_id,
key=args.key,
archive_file_kind=args.archive_file_kind,
archive_file_id=args.archive_file_id,
member_path=args.member_path,
)
except AgentDriveError as exc:
raise NotFound(exc.message) from exc
response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
response.headers["Content-Length"] = str(len(payload))
if args.as_attachment and filename:
encoded_filename = quote(filename)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
return response

View File

@ -16,7 +16,7 @@ from collections.abc import Generator, Mapping
from typing import Any
from flask import Flask, current_app
from sqlalchemy import select
from sqlalchemy import and_, or_, select
from clients.agent_backend import AgentBackendRunEventAdapter
from clients.agent_backend.factory import create_agent_backend_run_client
@ -42,7 +42,15 @@ from core.app.llm.model_access import build_dify_model_access
from core.ops.ops_trace_manager import TraceQueueManager
from extensions.ext_database import db
from models import Account, App, EndUser, Message
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigSnapshot,
AgentScope,
AgentSource,
AgentStatus,
)
from models.agent_config_entities import AgentSoulConfig
from services.conversation_service import ConversationService
@ -73,10 +81,15 @@ class AgentAppGenerator(MessageBasedAppGenerator):
inputs = args["inputs"]
# Resolve the bound roster Agent + its current Agent Soul snapshot.
agent, snapshot, agent_soul = self._resolve_agent(app_model)
agent, agent_config_id, agent_soul = self._resolve_agent(
app_model,
invoke_from=invoke_from,
draft_type=args.get("draft_type"),
user=user,
)
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
invoke_from=invoke_from,
snapshot_id=snapshot.id,
snapshot_id=agent_config_id,
)
conversation = None
@ -123,7 +136,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
call_depth=0,
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=snapshot.id,
agent_config_snapshot_id=agent_config_id,
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
)
@ -179,7 +192,12 @@ class AgentAppGenerator(MessageBasedAppGenerator):
persisted to the conversation. Live streaming to a reconnected client is
out of scope here the message is persisted and can be re-fetched.
"""
agent, snapshot, agent_soul = self._resolve_agent(app_model)
agent, agent_config_id, agent_soul = self._resolve_agent(
app_model,
invoke_from=invoke_from,
draft_type="draft",
user=user,
)
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=user
)
@ -226,7 +244,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
call_depth=0,
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=snapshot.id,
agent_config_snapshot_id=agent_config_id,
)
conversation, message = self._init_generate_records(application_generate_entity, conversation)
@ -421,50 +439,135 @@ class AgentAppGenerator(MessageBasedAppGenerator):
return False, query
def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
def _resolve_agent(
self,
app_model: App,
*,
invoke_from: InvokeFrom,
draft_type: Any,
user: Account | EndUser,
) -> tuple[Agent, str, AgentSoulConfig]:
agent = db.session.scalar(
select(Agent).where(
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.status == AgentStatus.ACTIVE,
or_(
and_(
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
),
Agent.backing_app_id == app_model.id,
),
)
.order_by(Agent.created_at.desc())
.limit(1)
)
if agent is None:
raise AgentAppGeneratorError("Agent App has no bound Agent")
return self._resolve_agent_by_id(
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
if invoke_from == InvokeFrom.DEBUGGER:
draft = self._resolve_debug_draft(
tenant_id=app_model.tenant_id,
agent=agent,
draft_type=draft_type,
account_id=user.id if isinstance(user, Account) else None,
)
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
return agent, draft.id, agent_soul
_, snapshot, agent_soul = self._resolve_agent_by_id(
tenant_id=app_model.tenant_id,
agent_id=agent.id,
snapshot_id=agent.active_config_snapshot_id,
)
return agent, snapshot.id, agent_soul
@staticmethod
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
"""Return the session scope snapshot id for Agent App runtime state.
Console preview/debug chat is an editing workspace: saving Agent Soul
creates replacement snapshots, but the user expects the same preview
conversation to keep context while trying prompt changes. Use a stable
NULL snapshot scope for debugger runs so each turn can use the latest
Agent Soul while reusing the conversation history. Published/web/API
runs keep snapshot-scoped sessions for reproducible runtime state.
Console preview/debug chat uses a stable Agent draft row id; build mode
uses the current user's build-draft row id. Published/web/API runs use
immutable published snapshot ids. This keeps runtime session continuity
inside one editable surface without mixing draft/build/published state.
"""
if invoke_from == InvokeFrom.DEBUGGER:
return None
return snapshot_id
@staticmethod
def _resolve_debug_draft(
*, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None
) -> AgentConfigDraft:
effective_draft_type = (
AgentConfigDraftType.DEBUG_BUILD
if draft_type == AgentConfigDraftType.DEBUG_BUILD.value
else AgentConfigDraftType.DRAFT
)
stmt = select(AgentConfigDraft).where(
AgentConfigDraft.tenant_id == tenant_id,
AgentConfigDraft.agent_id == agent.id,
AgentConfigDraft.draft_type == effective_draft_type,
)
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
if not account_id:
raise AgentAppGeneratorError("Build draft requires an account user")
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
else:
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
draft = db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
if draft is not None:
return draft
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
raise AgentAppGeneratorError("Agent build draft not found")
_, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id(
tenant_id=tenant_id,
agent_id=agent.id,
snapshot_id=agent.active_config_snapshot_id,
)
draft = AgentConfigDraft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft_owner_key="",
base_snapshot_id=snapshot.id,
config_snapshot=agent_soul,
created_by=agent.created_by,
updated_by=agent.updated_by,
)
db.session.add(draft)
db.session.flush()
return draft
@staticmethod
def _resolve_agent_by_id(
*, tenant_id: str, agent_id: str, snapshot_id: str | None
) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
) -> tuple[Agent, AgentConfigSnapshot | AgentConfigDraft, AgentSoulConfig]:
agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id))
if agent is None:
raise AgentAppGeneratorError("Agent not found")
if not snapshot_id:
raise AgentAppGeneratorError("Agent has no published version")
snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id))
if snapshot is None:
snapshot = db.session.scalar(
select(AgentConfigSnapshot).where(
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent_id,
AgentConfigSnapshot.id == snapshot_id,
)
)
if snapshot is not None:
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return agent, snapshot, agent_soul
draft = db.session.scalar(
select(AgentConfigDraft).where(
AgentConfigDraft.tenant_id == tenant_id,
AgentConfigDraft.agent_id == agent_id,
AgentConfigDraft.id == snapshot_id,
)
)
if draft is None:
raise AgentAppGeneratorError("Agent published version not found")
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return agent, snapshot, agent_soul
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
return agent, draft, agent_soul
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any
from models.agent_config_entities import AgentSoulConfig
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
{
@ -48,9 +49,7 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
)
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
reserved_status["knowledge"] = (
"supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured"
)
reserved_status["knowledge"] = "supported_by_knowledge_layer" if agent_soul.knowledge.sets else "not_configured"
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
reserved_status["env"] = "supported_by_shell_bootstrap"
@ -66,14 +65,14 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
def list_configured_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
"""Return the normalized knowledge dataset ids that can produce a runtime layer.
"""Return normalized dataset ids selected by Agent v2 knowledge sets.
``build_runtime_feature_manifest()`` and ``build_knowledge_layer_config()``
must stay aligned: both decide knowledge support from this effective,
non-blank dataset-id set rather than from raw
``agent_soul.knowledge.datasets`` entries.
stay aligned on the set-based contract: DTO validation rejects blank dataset
ids before runtime, so this helper only flattens configured set datasets for
metadata/diagnostic surfaces that still need a dataset-id summary.
"""
return [dataset_id for dataset in agent_soul.knowledge.datasets if (dataset_id := (dataset.id or "").strip())]
return list_agent_soul_knowledge_dataset_ids(agent_soul)
def _get_nested(value: dict[str, Any], path: str) -> Any:

View File

@ -15,7 +15,16 @@ from dify_agent.layers.execution_context import (
DifyExecutionContextLayerConfig,
DifyExecutionContextUserFrom,
)
from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig, DifyKnowledgeRetrievalConfig
from dify_agent.layers.knowledge import (
DifyKnowledgeBaseLayerConfig,
DifyKnowledgeDatasetConfig,
DifyKnowledgeMetadataFilteringConfig,
DifyKnowledgeModelConfig,
DifyKnowledgeQueryConfig,
DifyKnowledgeRerankingModelConfig,
DifyKnowledgeRetrievalConfig,
DifyKnowledgeSetConfig,
)
from dify_agent.layers.shell import (
DifyShellCliToolConfig,
DifyShellEnvVarConfig,
@ -40,7 +49,9 @@ from graphon.file import FileTransferMethod
from graphon.variables.segments import Segment
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentKnowledgeQueryConfig,
AgentKnowledgeMetadataFilteringConfig,
AgentKnowledgeModelConfig,
AgentKnowledgeRetrievalConfig,
AgentSoulConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
@ -64,7 +75,7 @@ from services.agent_drive_service import AgentDriveService, decode_drive_mention
from .output_failure_orchestrator import retry_idempotency_key
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids
from .runtime_feature_manifest import build_runtime_feature_manifest
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation")
@ -547,42 +558,84 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None:
"""Map Agent Soul knowledge config into the fixed Dify knowledge-base layer.
"""Map Agent Soul knowledge sets into one Dify knowledge-base layer.
Normalization intentionally matches the current dify-agent runtime contract:
- blank or missing dataset ids are ignored;
- if no valid dataset ids remain, no knowledge layer is injected;
- retrieval mode is always forced to ``multiple`` in this first wiring pass;
- ``top_k`` falls back to a stable runtime default when the soul omits it;
- ``score_threshold`` is only forwarded when the product config explicitly
enables it, otherwise the layer keeps the disabled/default ``0.0`` value;
- metadata filtering stays at the layer DTO default (disabled).
Agent Soul DTO validation owns malformed set rejection. Runtime mapping is
intentionally lossless: every configured set is forwarded with its query
policy, dataset refs, retrieval controls, and metadata-filtering controls.
``score_threshold=None`` means disabled threshold filtering and maps to the
inner retrieval request's ``0.0`` default through the Agent backend DTO.
"""
dataset_ids = list_configured_knowledge_dataset_ids(agent_soul)
if not dataset_ids:
if not agent_soul.knowledge.sets:
return None
query_config = agent_soul.knowledge.query_config
return DifyKnowledgeBaseLayerConfig(
dataset_ids=dataset_ids,
retrieval=DifyKnowledgeRetrievalConfig(
mode="multiple",
top_k=_knowledge_top_k(query_config),
score_threshold=_knowledge_score_threshold(query_config),
),
sets=[
DifyKnowledgeSetConfig(
id=knowledge_set.id,
name=knowledge_set.name,
description=knowledge_set.description,
datasets=[
DifyKnowledgeDatasetConfig(
id=dataset.id or "",
name=dataset.name,
description=dataset.description,
)
for dataset in knowledge_set.datasets
],
query=DifyKnowledgeQueryConfig(
mode=cast(Literal["user_query", "generated_query"], knowledge_set.query.mode.value),
value=knowledge_set.query.value,
),
retrieval=_knowledge_retrieval_config(knowledge_set.retrieval),
metadata_filtering=_knowledge_metadata_filtering_config(knowledge_set.metadata_filtering),
)
for knowledge_set in agent_soul.knowledge.sets
],
)
def _knowledge_top_k(query_config: AgentKnowledgeQueryConfig) -> int:
top_k = query_config.top_k
return top_k if isinstance(top_k, int) and top_k >= 1 else 4
def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> DifyKnowledgeRetrievalConfig:
return DifyKnowledgeRetrievalConfig(
mode=retrieval.mode,
top_k=retrieval.top_k,
score_threshold=retrieval.score_threshold or 0.0,
reranking_mode=retrieval.reranking_mode,
reranking_enable=retrieval.reranking_enable,
reranking_model=DifyKnowledgeRerankingModelConfig(
provider=retrieval.reranking_model.provider,
model=retrieval.reranking_model.model,
)
if retrieval.reranking_model is not None
else None,
weights=cast(dict[str, Any], retrieval.weights.model_dump(mode="json", exclude_none=True))
if retrieval.weights is not None
else None,
model=_knowledge_model_config(retrieval.model),
)
def _knowledge_score_threshold(query_config: AgentKnowledgeQueryConfig) -> float:
if query_config.score_threshold_enabled and query_config.score_threshold is not None:
return query_config.score_threshold
return 0.0
def _knowledge_metadata_filtering_config(
metadata_filtering: AgentKnowledgeMetadataFilteringConfig,
) -> DifyKnowledgeMetadataFilteringConfig:
return DifyKnowledgeMetadataFilteringConfig(
mode=metadata_filtering.mode,
model_config=_knowledge_model_config(metadata_filtering.metadata_model_config),
conditions=cast(Any, metadata_filtering.conditions.model_dump(mode="json"))
if metadata_filtering.conditions is not None
else None,
)
def _knowledge_model_config(model: AgentKnowledgeModelConfig | None) -> DifyKnowledgeModelConfig | None:
if model is None:
return None
return DifyKnowledgeModelConfig(
provider=model.provider,
name=model.name,
mode=model.mode,
completion_params=model.completion_params,
)
def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None:

View File

@ -18,6 +18,7 @@ from models.agent_config_entities import (
)
from models.model import UploadFile
from models.workflow import Workflow
from services.agent.knowledge_datasets import list_missing_tenant_knowledge_dataset_ids
from .entities import DifyAgentNodeData
@ -146,6 +147,7 @@ class WorkflowAgentNodeValidator:
)
cls._validate_agent_soul_env(binding=binding, agent_soul=agent_soul)
cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul)
cls._validate_agent_soul_knowledge(binding=binding, agent_soul=agent_soul)
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
@ -364,6 +366,24 @@ class WorkflowAgentNodeValidator:
)
cli_tool_names.add(normalized_name)
@classmethod
def _validate_agent_soul_knowledge(
cls,
*,
binding: WorkflowAgentNodeBinding,
agent_soul: AgentSoulConfig,
) -> None:
"""Validate knowledge set dataset rows against the publishing tenant."""
missing_ids = list_missing_tenant_knowledge_dataset_ids(
tenant_id=binding.tenant_id,
agent_soul=agent_soul,
)
if missing_ids:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references missing or out-of-scope knowledge datasets: "
f"{', '.join(missing_ids)}."
)
@classmethod
def _validate_agent_soul_env(
cls,

View File

@ -6,6 +6,7 @@ from pydantic import Field, field_validator
from fields.base import ResponseModel
from libs.helper import to_timestamp
from models.agent import (
AgentConfigDraftType,
AgentConfigRevisionOperation,
AgentIconType,
AgentKind,
@ -47,6 +48,18 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel):
created_at: int | None = None
class AgentConfigDraftSummaryResponse(ResponseModel):
id: str
agent_id: str
draft_type: AgentConfigDraftType
account_id: str | None = None
base_snapshot_id: str | None = None
created_by: str | None = None
updated_by: str | None = None
created_at: int | None = None
updated_at: int | None = None
class AgentPublishedReferenceResponse(ResponseModel):
app_id: str
app_name: str
@ -72,6 +85,8 @@ class AgentRosterResponse(ResponseModel):
scope: AgentScope
source: AgentSource
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
workflow_id: str | None = None
workflow_node_id: str | None = None
active_config_snapshot_id: str | None = None
@ -292,14 +307,24 @@ class AgentConfigSnapshotListResponse(ResponseModel):
class AgentConfigSnapshotRestoreResponse(ResponseModel):
result: Literal["success"]
active_config_snapshot_id: str
draft_config_id: str | None = None
restored_version_id: str | None = None
class AgentComposerAgentResponse(ResponseModel):
id: str
name: str
description: str
role: str | None = None
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
scope: AgentScope
source: AgentSource | None = None
status: AgentStatus
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
active_config_snapshot_id: str | None = None
@ -343,6 +368,9 @@ class WorkflowAgentComposerResponse(ResponseModel):
impact_summary: AgentComposerImpactResponse | None = None
validation: "ComposerValidationFindingsResponse | None" = None
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
chat_endpoint: str | None = None
workflow_id: str | None = None
node_id: str | None = None
@ -350,10 +378,15 @@ class WorkflowAgentComposerResponse(ResponseModel):
class AgentAppComposerResponse(ResponseModel):
variant: Literal[ComposerVariant.AGENT_APP]
agent: AgentComposerAgentResponse
active_config_snapshot: AgentConfigSnapshotSummaryResponse
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
draft: AgentConfigDraftSummaryResponse | None = None
agent_soul: AgentSoulConfig
save_options: list[ComposerSaveStrategy]
validation: "ComposerValidationFindingsResponse | None" = None
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
chat_endpoint: str | None = None
class ComposerValidationWarningResponse(ResponseModel):
@ -400,10 +433,22 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
class AgentComposerKnowledgeDatasetCandidateResponse(AgentKnowledgeDatasetConfig):
missing: bool = False
class AgentComposerKnowledgeSetCandidateResponse(ResponseModel):
id: str
name: str
description: str | None = None
datasets: list[AgentComposerKnowledgeDatasetCandidateResponse] = Field(default_factory=list)
missing_dataset_ids: list[str] = Field(default_factory=list)
class AgentComposerSoulCandidatesResponse(ResponseModel):
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
knowledge_sets: list[AgentComposerKnowledgeSetCandidateResponse] = Field(default_factory=list)
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)

View File

@ -1,16 +1,5 @@
"""add workflow_version to workflow_agent_node_bindings
Restores the stage 1 §5.3 unique key
``(tenant_id, workflow_id, workflow_version, node_id)`` so draft and published
workflow bindings can coexist at the same workflow_id once we want to track
them per workflow version. ``workflow_version`` mirrors ``workflows.version``
("draft" or a published version string).
Because the New Agent Experience feature is pre-release, this table is empty
in every environment that matters; the ``server_default='draft'`` only exists
to keep developer-local rows valid during the alter and is dropped immediately
afterward so application code must specify ``workflow_version`` explicitly.
Revision ID: 97e2e1a644e8
Revises: f8b6b7e9c421
Create Date: 2026-05-25 11:43:37.611300
@ -33,10 +22,8 @@ def upgrade():
'workflow_version',
sa.String(length=255),
nullable=False,
server_default='draft',
)
)
batch_op.alter_column('workflow_version', server_default=None)
batch_op.drop_constraint(
batch_op.f('workflow_agent_node_binding_node_unique'), type_='unique'
)

View File

@ -18,8 +18,7 @@ depends_on = None
def upgrade():
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False, server_default=""))
batch_op.alter_column("role", server_default=None)
batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False))
def downgrade():

View File

@ -6,15 +6,9 @@ Create Date: 2026-06-18 23:00:00.000000
"""
from __future__ import annotations
import json
from typing import Any
from alembic import op
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
from sqlalchemy.engine.mock import MockConnection
# revision identifiers, used by Alembic.
revision = "b2515f9d4c2a"
@ -37,46 +31,9 @@ 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

View File

@ -0,0 +1,56 @@
"""add agent config drafts
Revision ID: e4f5a6b7c8d9
Revises: d9e8f7a6b5c4
Create Date: 2026-06-24 20:15:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "e4f5a6b7c8d9"
down_revision = "d9e8f7a6b5c4"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"agent_config_drafts",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
sa.Column("draft_type", sa.String(length=32), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=True),
sa.Column("draft_owner_key", sa.String(length=255), nullable=False),
sa.Column("base_snapshot_id", models.types.StringUUID(), nullable=True),
sa.Column("config_snapshot", models.types.LongText(), nullable=False),
sa.Column("created_by", models.types.StringUUID(), nullable=True),
sa.Column("updated_by", models.types.StringUUID(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("agent_config_draft_pkey")),
sa.UniqueConstraint(
"tenant_id",
"agent_id",
"draft_type",
"draft_owner_key",
name=op.f("agent_config_draft_agent_type_account_unique"),
),
)
op.create_index("agent_config_draft_tenant_agent_idx", "agent_config_drafts", ["tenant_id", "agent_id"])
op.create_index(
"agent_config_draft_base_snapshot_idx",
"agent_config_drafts",
["tenant_id", "base_snapshot_id"],
)
def downgrade():
op.drop_index("agent_config_draft_base_snapshot_idx", table_name="agent_config_drafts")
op.drop_index("agent_config_draft_tenant_agent_idx", table_name="agent_config_drafts")
op.drop_table("agent_config_drafts")

View File

@ -0,0 +1,30 @@
"""add agent backing app id
Revision ID: a2b3c4d5e6f7
Revises: e4f5a6b7c8d9
Create Date: 2026-06-25 11:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "a2b3c4d5e6f7"
down_revision = "e4f5a6b7c8d9"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.add_column(sa.Column("backing_app_id", models.types.StringUUID(), nullable=True))
op.create_index("agent_tenant_backing_app_id_idx", "agents", ["tenant_id", "backing_app_id"])
def downgrade():
op.drop_index("agent_tenant_backing_app_id_idx", table_name="agents")
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.drop_column("backing_app_id")

View File

@ -10,6 +10,8 @@ from .account import (
)
from .agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@ -154,6 +156,8 @@ __all__ = [
"AccountStatus",
"AccountTrialAppRecord",
"Agent",
"AgentConfigDraft",
"AgentConfigDraftType",
"AgentConfigRevision",
"AgentConfigRevisionOperation",
"AgentConfigSnapshot",

View File

@ -85,6 +85,17 @@ class AgentConfigRevisionOperation(StrEnum):
SAVE_TO_ROSTER = "save_to_roster"
# Switches the Agent's current published config back to an existing version.
RESTORE_VERSION = "restore_version"
# Publishes the editable Agent Soul draft as a new immutable version.
PUBLISH_DRAFT = "publish_draft"
class AgentConfigDraftType(StrEnum):
"""Editable Agent Soul draft workspace type."""
# Shared Agent Console draft edited by users before publishing.
DRAFT = "draft"
# Per-editor build draft mutated during debug/build mode.
DEBUG_BUILD = "debug_build"
class WorkflowAgentBindingType(StrEnum):
@ -134,6 +145,7 @@ class Agent(DefaultFieldsMixin, Base):
Index("agent_tenant_scope_idx", "tenant_id", "scope"),
Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"),
Index("agent_tenant_app_id_idx", "tenant_id", "app_id"),
Index("agent_tenant_backing_app_id_idx", "tenant_id", "backing_app_id"),
Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"),
Index(
"agent_tenant_invitable_idx",
@ -162,6 +174,14 @@ class Agent(DefaultFieldsMixin, Base):
scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False)
source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False)
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
backing_app_id: Mapped[str | None] = mapped_column(
StringUUID,
nullable=True,
comment=(
"Runtime Agent App used for chat/log/monitoring. For workflow-only agents, "
"app_id remains the parent workflow app id and this points to the hidden backing app."
),
)
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
@ -210,6 +230,44 @@ class AgentDebugConversation(DefaultFieldsMixin, Base):
conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
class AgentConfigDraft(DefaultFieldsMixin, Base):
"""Editable Agent Soul draft separated from immutable published snapshots."""
__tablename__ = "agent_config_drafts"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_config_draft_pkey"),
UniqueConstraint(
"tenant_id",
"agent_id",
"draft_type",
"draft_owner_key",
name="agent_config_draft_agent_type_account_unique",
),
Index("agent_config_draft_tenant_agent_idx", "tenant_id", "agent_id"),
Index("agent_config_draft_base_snapshot_idx", "tenant_id", "base_snapshot_id"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
draft_type: Mapped[AgentConfigDraftType] = mapped_column(EnumText(AgentConfigDraftType, length=32), nullable=False)
account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="")
base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
@property
def config_snapshot_dict(self) -> dict[str, Any]:
if not self.config_snapshot:
return {}
if hasattr(self.config_snapshot, "model_dump"):
return self.config_snapshot.model_dump(mode="json")
if isinstance(self.config_snapshot, str):
return json.loads(self.config_snapshot)
return dict(self.config_snapshot)
class AgentConfigSnapshot(DefaultFieldsMixin, Base):
"""Immutable Agent Soul snapshot.
@ -355,9 +413,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base):
agent_config_snapshot_id / composition_layer_specs`` columns are set.
- Agent App conversations: ``owner_type = conversation``; the
``conversation_id`` column is set and the workflow columns stay NULL.
Published/web/API runs scope runtime state by ``agent_config_snapshot_id``;
console debugger runs may keep it NULL so prompt-only draft saves can reuse
the same preview conversation state while executing the latest Agent Soul.
Runtime state is scoped by ``agent_config_snapshot_id``. For published
web/API runs this points to an immutable AgentConfigSnapshot; for console
debugger/build runs it points to the editable AgentConfigDraft row.
The snapshot is runtime state returned by Agent backend, kept separate from
Agent Soul snapshots and workflow node-job config.

View File

@ -2,10 +2,11 @@ from __future__ import annotations
import re
from enum import StrEnum
from typing import Annotated, Any, Final, Literal
from typing import Annotated, Any, Final, Literal, Self
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator
from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator
from core.workflow.file_reference import is_canonical_file_reference
from graphon.file import FileTransferMethod
@ -161,6 +162,11 @@ class AgentSkillRefConfig(AgentFlexibleConfig):
manifest_files: list[str] | None = None
class AgentSoulFilesConfig(BaseModel):
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
files: list[AgentFileRefConfig] = Field(default_factory=list)
class AgentPermissionConfig(BaseModel):
model_config = ConfigDict(extra="ignore")
@ -236,17 +242,161 @@ class AgentCliToolConfig(AgentFlexibleConfig):
inferred_from: str | None = Field(default=None, max_length=255)
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):
class AgentKnowledgeDatasetConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str | None = Field(default=None, max_length=255)
name: str | None = Field(default=None, max_length=255)
description: str | None = None
class AgentKnowledgeQueryConfig(AgentFlexibleConfig):
query: str | None = None
class AgentKnowledgeQueryConfig(BaseModel):
"""Per-set query policy for Agent v2 knowledge retrieval.
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
set owns its own query policy, so ``user_query`` must carry an explicit
``value`` while ``generated_query`` leaves that value empty.
"""
model_config = ConfigDict(extra="forbid")
mode: AgentKnowledgeQueryMode
value: str | None = None
@model_validator(mode="after")
def validate_query(self) -> Self:
if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip():
raise ValueError("knowledge query.value is required for user_query mode")
return self
class AgentKnowledgeModelConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
provider: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=255)
mode: str = Field(min_length=1, max_length=64)
completion_params: dict[str, Any] = Field(default_factory=dict)
class AgentKnowledgeRerankingModelConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
provider: str = Field(min_length=1, max_length=255)
model: str = Field(min_length=1, max_length=255)
class AgentKnowledgeWeightedScoreConfig(AgentFlexibleConfig):
weight_type: str | None = Field(default=None, max_length=64)
vector_setting: dict[str, Any] | None = None
keyword_setting: dict[str, Any] | None = None
class AgentKnowledgeRetrievalConfig(BaseModel):
"""Per-set retrieval policy for Agent v2 knowledge retrieval.
Retrieval settings now live on each knowledge set instead of one shared
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
``single`` retrieval with a required model config.
"""
model_config = ConfigDict(extra="forbid")
mode: Literal["single", "multiple"]
top_k: int | None = Field(default=None, ge=1)
score_threshold: float | None = Field(default=None, ge=0, le=1)
score_threshold_enabled: bool | None = None
reranking_mode: str = "reranking_model"
reranking_enable: bool = True
reranking_model: AgentKnowledgeRerankingModelConfig | None = None
weights: AgentKnowledgeWeightedScoreConfig | None = None
model: AgentKnowledgeModelConfig | None = None
@model_validator(mode="after")
def validate_mode_fields(self) -> Self:
if self.mode == "multiple" and self.top_k is None:
raise ValueError("knowledge retrieval.top_k is required for multiple mode")
if self.mode == "single" and self.model is None:
raise ValueError("knowledge retrieval.model is required for single mode")
return self
class AgentKnowledgeMetadataCondition(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
comparison_operator: SupportedComparisonOperator
value: ConditionValue = None
class AgentKnowledgeMetadataConditions(BaseModel):
model_config = ConfigDict(extra="forbid")
logical_operator: Literal["and", "or"] = "and"
conditions: list[AgentKnowledgeMetadataCondition] = Field(default_factory=list)
class AgentKnowledgeMetadataFilteringConfig(BaseModel):
"""Per-set metadata filtering policy.
The Python attribute uses ``metadata_model_config`` for clarity because the
model belongs to metadata filtering specifically, while the external API and
generated schema keep the historical ``model_config`` field name via alias.
"""
model_config = ConfigDict(extra="forbid", populate_by_name=True)
mode: Literal["disabled", "automatic", "manual"] = "disabled"
# Internal name is explicit; wire format remains ``model_config``.
metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config")
conditions: AgentKnowledgeMetadataConditions | None = None
@model_validator(mode="after")
def validate_mode_fields(self) -> Self:
if self.mode == "automatic" and self.metadata_model_config is None:
raise ValueError("metadata_filtering.model_config is required for automatic mode")
if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions):
raise ValueError("metadata_filtering.conditions is required for manual mode")
return self
class AgentKnowledgeSetConfig(BaseModel):
"""One explicit knowledge set in Agent v2.
``knowledge.sets`` replaces the old flat knowledge config. Each set owns
its datasets plus query, retrieval, and metadata policies. An individual
set must contain at least one dataset id even though the overall knowledge
section may be empty, which is how callers express "no knowledge layer".
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
datasets: list[AgentKnowledgeDatasetConfig]
query: AgentKnowledgeQueryConfig
retrieval: AgentKnowledgeRetrievalConfig
metadata_filtering: AgentKnowledgeMetadataFilteringConfig = Field(
default_factory=AgentKnowledgeMetadataFilteringConfig
)
@field_validator("id", "name")
@classmethod
def validate_non_blank_identity(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("knowledge set id and name must not be blank")
return normalized
@model_validator(mode="after")
def validate_datasets(self) -> Self:
dataset_ids = [(dataset.id or "").strip() for dataset in self.datasets]
if not dataset_ids or any(not dataset_id for dataset_id in dataset_ids):
raise ValueError("knowledge set requires at least one dataset id")
if len(dataset_ids) != len(set(dataset_ids)):
raise ValueError("knowledge set dataset ids must be unique")
return self
class AgentHumanContactConfig(AgentFlexibleConfig):
@ -453,9 +603,28 @@ class AgentSoulToolsConfig(BaseModel):
class AgentSoulKnowledgeConfig(BaseModel):
datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
query_mode: AgentKnowledgeQueryMode | None = None
query_config: AgentKnowledgeQueryConfig = Field(default_factory=AgentKnowledgeQueryConfig)
"""Top-level Agent v2 knowledge config.
Agent v2 models knowledge as explicit sets instead of one flat
``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
list means no knowledge layer should be emitted at runtime, while set-name
uniqueness stays case-insensitive because runtime selection addresses sets
by name.
"""
model_config = ConfigDict(extra="forbid")
sets: list[AgentKnowledgeSetConfig] = Field(default_factory=list)
@model_validator(mode="after")
def validate_unique_sets(self) -> Self:
set_ids = [item.id.strip() for item in self.sets]
if len(set_ids) != len(set(set_ids)):
raise ValueError("knowledge set ids must be unique")
set_names = [item.name.strip().lower() for item in self.sets]
if len(set_names) != len(set(set_names)):
raise ValueError("knowledge set names must be unique")
return self
class AgentSoulHumanConfig(BaseModel):
@ -513,6 +682,7 @@ class AgentSoulConfig(BaseModel):
knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig)
human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig)
env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig)
files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
model: AgentSoulModelConfig | None = None

View File

@ -487,9 +487,15 @@ class App(Base):
agent = db.session.scalar(
select(Agent).where(
Agent.app_id == self.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.tenant_id == self.tenant_id,
sa.or_(
sa.and_(
Agent.app_id == self.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
),
Agent.backing_app_id == self.id,
),
Agent.status == AgentStatus.ACTIVE,
)
)

View File

@ -465,6 +465,83 @@ Check if activation token is valid
| ---- | ----------- |
| 204 | Agent service API key deleted |
### [DELETE] /agent/{agent_id}/build-draft
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent build draft discarded | **application/json**: [AgentSimpleResultResponse](#agentsimpleresultresponse)<br> |
### [GET] /agent/{agent_id}/build-draft
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent build draft | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
### [PUT] /agent/{agent_id}/build-draft
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent build draft saved | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
### [POST] /agent/{agent_id}/build-draft/apply
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent build draft applied | **application/json**: [AgentBuildDraftApplyResponse](#agentbuilddraftapplyresponse)<br> |
### [POST] /agent/{agent_id}/build-draft/checkout
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentBuildDraftCheckoutPayload](#agentbuilddraftcheckoutpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent build draft checked out | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)<br> |
### [GET] /agent/{agent_id}/chat-messages
Get Agent App chat messages for a conversation with pagination
@ -856,6 +933,26 @@ Get Agent App message details by ID
| 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)<br> |
| 404 | Agent or message not found | |
### [POST] /agent/{agent_id}/publish
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentPublishPayload](#agentpublishpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent draft published | **application/json**: [AgentPublishResponse](#agentpublishresponse)<br> |
| 403 | Insufficient permissions | |
### [GET] /agent/{agent_id}/referencing-workflows
List workflow apps that reference this Agent App's bound Agent (read-only)
@ -3764,6 +3861,7 @@ Submit human input form preview for workflow
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| snapshot_id | query | | No | string |
| app_id | path | | Yes | string (uuid) |
| node_id | path | | Yes | string |
@ -12170,9 +12268,14 @@ Default namespace
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| app_id | string | | No |
| backing_app_id | string | | No |
| chat_endpoint | string | | No |
| draft | [AgentConfigDraftSummaryResponse](#agentconfigdraftsummaryresponse) | | No |
| hidden_app_backed | boolean | | No |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
@ -12207,6 +12310,7 @@ Default namespace
| active_config_is_published | boolean | | No |
| api_base_url | string | | No |
| app_id | string | | No |
| backing_app_id | string | | No |
| bound_agent_id | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
@ -12215,6 +12319,7 @@ Default namespace
| description | string | | No |
| enable_api | boolean | | Yes |
| enable_site | boolean | | Yes |
| hidden_app_backed | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
@ -12227,7 +12332,7 @@ Default namespace
| name | string | | Yes |
| permission_keys | [ string ] | | No |
| role | string | | No |
| site | [Site](#site) | | No |
| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No |
| tags | [ [Tag](#tag) ] | | No |
| tracing | [JSONValue](#jsonvalue) | | No |
| updated_at | integer | | No |
@ -12270,6 +12375,7 @@ default (the config form sends the full desired feature state on save).
| active_config_is_published | boolean | | No |
| app_id | string | | No |
| author_name | string | | No |
| backing_app_id | string | | No |
| bound_agent_id | string | | No |
| create_user_name | string | | No |
| created_at | integer | | No |
@ -12277,6 +12383,7 @@ default (the config form sends the full desired feature state on save).
| debug_conversation_id | string | | No |
| description | string | | No |
| has_draft_trigger | boolean | | No |
| hidden_app_backed | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
@ -12335,6 +12442,27 @@ default (the config form sends the full desired feature state on save).
| date | string | | Yes |
| interactions | number | | Yes |
#### AgentBuildDraftApplyResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| draft | object | | Yes |
| result | string | | Yes |
#### AgentBuildDraftCheckoutPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| force | boolean | Overwrite the existing current-user build draft | No |
#### AgentBuildDraftResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_soul | object | | Yes |
| draft | object | | Yes |
| variant | string | | Yes |
#### AgentCliToolAuthorizationStatus
Authorization state for Agent-scoped CLI tools.
@ -12397,10 +12525,18 @@ Risk marker for CLI tool bootstrap commands.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot_id | string | | No |
| app_id | string | | No |
| backing_app_id | string | | No |
| description | string | | Yes |
| hidden_app_backed | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| id | string | | Yes |
| name | string | | Yes |
| role | string | | No |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | No |
| status | [AgentStatus](#agentstatus) | | Yes |
#### AgentComposerBindingResponse
@ -12453,6 +12589,25 @@ Risk marker for CLI tool bootstrap commands.
| current_snapshot_id | string | | No |
| workflow_node_count | integer | | Yes |
#### AgentComposerKnowledgeDatasetCandidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| id | string | | No |
| missing | boolean | | No |
| name | string | | No |
#### AgentComposerKnowledgeSetCandidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| datasets | [ [AgentComposerKnowledgeDatasetCandidateResponse](#agentcomposerknowledgedatasetcandidateresponse) ] | | No |
| description | string | | No |
| id | string | | Yes |
| missing_dataset_ids | [ string ] | | No |
| name | string | | Yes |
#### AgentComposerNodeJobCandidatesResponse
| Name | Type | Description | Required |
@ -12468,7 +12623,7 @@ Risk marker for CLI tool bootstrap commands.
| cli_tools | [ [AgentCliToolConfig](#agentclitoolconfig) ] | | No |
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
| knowledge_sets | [ [AgentComposerKnowledgeSetCandidateResponse](#agentcomposerknowledgesetcandidateresponse) ] | | No |
#### AgentComposerSoulLockResponse
@ -12487,6 +12642,28 @@ Risk marker for CLI tool bootstrap commands.
| result | string | | Yes |
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
#### AgentConfigDraftSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| account_id | string | | No |
| agent_id | string | | Yes |
| base_snapshot_id | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | | Yes |
| id | string | | Yes |
| updated_at | integer | | No |
| updated_by | string | | No |
#### AgentConfigDraftType
Editable Agent Soul draft workspace type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentConfigDraftType | string | Editable Agent Soul draft workspace type. | |
#### AgentConfigRevisionOperation
Audit operation recorded for Agent Soul version/revision changes.
@ -12536,6 +12713,8 @@ Audit operation recorded for Agent Soul version/revision changes.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot_id | string | | Yes |
| draft_config_id | string | | No |
| restored_version_id | string | | No |
| result | string | | Yes |
#### AgentConfigSnapshotSummaryResponse
@ -12790,10 +12969,12 @@ Supported icon storage formats for Agent roster entries.
| app_id | string | | No |
| archived_at | integer | | No |
| archived_by | string | | No |
| backing_app_id | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| description | string | | Yes |
| existing_node_ids | [ string ] | | No |
| hidden_app_backed | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
@ -12862,14 +13043,57 @@ the current roster/workflow APIs scoped to Dify Agent.
| id | string | | No |
| name | string | | No |
#### AgentKnowledgeQueryConfig
#### AgentKnowledgeMetadataCondition
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| query | string | | No |
| score_threshold | number | | No |
| score_threshold_enabled | boolean | | No |
| top_k | integer | | No |
| comparison_operator | string, <br>**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes |
| name | string | | Yes |
| value | string<br>[ string ]<br>number | | No |
#### AgentKnowledgeMetadataConditions
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conditions | [ [AgentKnowledgeMetadataCondition](#agentknowledgemetadatacondition) ] | | No |
| logical_operator | string, <br>**Available values:** "and", "or", <br>**Default:** and | *Enum:* `"and"`, `"or"` | No |
#### AgentKnowledgeMetadataFilteringConfig
Per-set metadata filtering policy.
The Python attribute uses ``metadata_model_config`` for clarity because the
model belongs to metadata filtering specifically, while the external API and
generated schema keep the historical ``model_config`` field name via alias.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conditions | [AgentKnowledgeMetadataConditions](#agentknowledgemetadataconditions) | | No |
| mode | string, <br>**Available values:** "automatic", "disabled", "manual", <br>**Default:** disabled | *Enum:* `"automatic"`, `"disabled"`, `"manual"` | No |
| model_config | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
#### AgentKnowledgeModelConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| completion_params | object | | No |
| mode | string | | Yes |
| name | string | | Yes |
| provider | string | | Yes |
#### AgentKnowledgeQueryConfig
Per-set query policy for Agent v2 knowledge retrieval.
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
set owns its own query policy, so ``user_query`` must carry an explicit
``value`` while ``generated_query`` leaves that value empty.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | Yes |
| value | string | | No |
#### AgentKnowledgeQueryMode
@ -12877,6 +13101,59 @@ the current roster/workflow APIs scoped to Dify Agent.
| ---- | ---- | ----------- | -------- |
| AgentKnowledgeQueryMode | string | | |
#### AgentKnowledgeRerankingModelConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| model | string | | Yes |
| provider | string | | Yes |
#### AgentKnowledgeRetrievalConfig
Per-set retrieval policy for Agent v2 knowledge retrieval.
Retrieval settings now live on each knowledge set instead of one shared
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
``single`` retrieval with a required model config.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mode | string, <br>**Available values:** "multiple", "single" | *Enum:* `"multiple"`, `"single"` | Yes |
| model | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
| reranking_enable | boolean, <br>**Default:** true | | No |
| reranking_mode | string, <br>**Default:** reranking_model | | No |
| reranking_model | [AgentKnowledgeRerankingModelConfig](#agentknowledgererankingmodelconfig) | | No |
| score_threshold | number | | No |
| top_k | integer | | No |
| weights | [AgentKnowledgeWeightedScoreConfig](#agentknowledgeweightedscoreconfig) | | No |
#### AgentKnowledgeSetConfig
One explicit knowledge set in Agent v2.
``knowledge.sets`` replaces the old flat knowledge config. Each set owns
its datasets plus query, retrieval, and metadata policies. An individual
set must contain at least one dataset id even though the overall knowledge
section may be empty, which is how callers express "no knowledge layer".
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | Yes |
| description | string | | No |
| id | string | | Yes |
| metadata_filtering | [AgentKnowledgeMetadataFilteringConfig](#agentknowledgemetadatafilteringconfig) | | No |
| name | string | | Yes |
| query | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | Yes |
| retrieval | [AgentKnowledgeRetrievalConfig](#agentknowledgeretrievalconfig) | | Yes |
#### AgentKnowledgeWeightedScoreConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword_setting | object | | No |
| vector_setting | object | | No |
| weight_type | string | | No |
#### AgentLogConversationItemResponse
| Name | Type | Description | Required |
@ -13060,6 +13337,21 @@ the current roster/workflow APIs scoped to Dify Agent.
| ---- | ---- | ----------- | -------- |
| AgentProviderResponse | object | | |
#### AgentPublishPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| version_note | string | Optional note for this published Agent version | No |
#### AgentPublishResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | object | | No |
| active_config_snapshot_id | string | | Yes |
| draft | object | | No |
| result | string | | Yes |
#### AgentPublishedReferenceResponse
| Name | Type | Description | Required |
@ -13117,9 +13409,11 @@ the current roster/workflow APIs scoped to Dify Agent.
| app_id | string | | No |
| archived_at | integer | | No |
| archived_by | string | | No |
| backing_app_id | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| description | string | | Yes |
| hidden_app_backed | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
@ -13187,6 +13481,27 @@ Visibility and lifecycle scope of an Agent record.
| enabled | boolean | | No |
| type | string | | No |
#### AgentSimpleResultResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### AgentSkillRefConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| file_id | string | | No |
| full_archive_file_id | string | | No |
| full_archive_key | string | | No |
| id | string | | No |
| manifest_files | [ string ] | | No |
| name | string | | No |
| path | string | | No |
| skill_md_file_id | string | | No |
| skill_md_key | string | | No |
#### AgentSkillUploadResponse
| Name | Type | Description | Required |
@ -13213,6 +13528,7 @@ Visibility and lifecycle scope of an Agent record.
| app_features | [AgentSoulAppFeaturesConfig](#agentsoulappfeaturesconfig) | | No |
| app_variables | [ [AppVariableConfig](#appvariableconfig) ] | | No |
| env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No |
| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No |
| human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No |
| knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No |
| memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No |
@ -13267,6 +13583,13 @@ old Agent tool payloads can be read while new payloads stay explicit.
| secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No |
| variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No |
#### AgentSoulFilesConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No |
| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
#### AgentSoulHumanConfig
| Name | Type | Description | Required |
@ -13276,11 +13599,17 @@ old Agent tool payloads can be read while new payloads stay explicit.
#### AgentSoulKnowledgeConfig
Top-level Agent v2 knowledge config.
Agent v2 models knowledge as explicit sets instead of one flat
``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
list means no knowledge layer should be emitted at runtime, while set-name
uniqueness stays case-insensitive because runtime selection addresses sets
by name.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
| query_config | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | No |
| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No |
| sets | [ [AgentKnowledgeSetConfig](#agentknowledgesetconfig) ] | | No |
#### AgentSoulMemoryConfig
@ -13754,6 +14083,35 @@ Enum class for api provider schema type.
| use_icon_as_answer_icon | boolean | | No |
| workflow | [WorkflowPartial](#workflowpartial) | | No |
#### AppDetailSiteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| access_token | string | | No |
| app_base_url | string | | No |
| chat_color_theme | string | | No |
| chat_color_theme_inverted | boolean | | No |
| code | string | | No |
| copyright | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| custom_disclaimer | string | | No |
| customize_domain | string | | No |
| customize_token_strategy | string | | No |
| default_language | string | | No |
| description | string | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string<br>[IconType](#icontype) | | No |
| icon_url | string | | Yes |
| privacy_policy | string | | No |
| prompt_public | boolean | | No |
| show_workflow_steps | boolean | | No |
| title | string | | No |
| updated_at | integer | | No |
| updated_by | string | | No |
| use_icon_as_answer_icon | boolean | | No |
#### AppDetailWithSite
| Name | Type | Description | Required |
@ -13779,7 +14137,7 @@ Enum class for api provider schema type.
| model_config | [ModelConfig](#modelconfig) | | No |
| name | string | | Yes |
| permission_keys | [ string ] | | No |
| site | [Site](#site) | | No |
| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No |
| tags | [ [Tag](#tag) ] | | No |
| tracing | [JSONValue](#jsonvalue) | | No |
| updated_at | integer | | No |
@ -14183,6 +14541,7 @@ Button styles for user actions.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conversation_id | string | Conversation ID | No |
| draft_type | string, <br>**Available values:** "debug_build", "draft", <br>**Default:** draft | Agent App debug config source. Use debug_build while the Agent is in build mode.<br>*Enum:* `"debug_build"`, `"draft"` | No |
| files | [ object ] | Uploaded files | No |
| inputs | object | | Yes |
| model_config | object | | No |
@ -20349,6 +20708,12 @@ How a workflow node is bound to an Agent.
| ---- | ---- | ----------- | -------- |
| WorkflowAgentBindingType | string | How a workflow node is bound to an Agent. | |
#### WorkflowAgentComposerQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| snapshot_id | string | | No |
#### WorkflowAgentComposerResponse
| Name | Type | Description | Required |
@ -20357,8 +20722,11 @@ How a workflow node is bound to an Agent.
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | No |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| app_id | string | | No |
| backing_app_id | string | | No |
| binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No |
| chat_endpoint | string | | No |
| effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No |
| hidden_app_backed | boolean | | No |
| impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No |
| node_id | string | | No |
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |

View File

@ -25,6 +25,7 @@ from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
)
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
MAX_CANDIDATES_PER_LIST = 200
@ -139,19 +140,34 @@ def soul_candidates(
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]
dataset_ids = list_agent_soul_knowledge_dataset_ids(soul)
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
knowledge_datasets: list[dict[str, Any]] = []
for dataset in soul.knowledge.datasets:
if not dataset.id:
continue
row = dataset_rows.get(dataset.id)
knowledge_datasets.append(
knowledge_sets: list[dict[str, Any]] = []
for knowledge_set in soul.knowledge.sets:
missing_dataset_ids: list[str] = []
datasets: list[dict[str, Any]] = []
for dataset in knowledge_set.datasets:
dataset_id = (dataset.id or "").strip()
if not dataset_id:
continue
row = dataset_rows.get(dataset_id)
if row is None:
missing_dataset_ids.append(dataset_id)
datasets.append(
{
"id": dataset_id,
"name": (getattr(row, "name", None) or dataset.name or dataset_id),
"description": getattr(row, "description", None) or dataset.description,
"missing": row is None,
}
)
knowledge_sets.append(
{
"id": dataset.id,
"name": (getattr(row, "name", None) or dataset.name or dataset.id),
"description": getattr(row, "description", None) or dataset.description,
"missing": row is None,
"id": knowledge_set.id,
"name": knowledge_set.name,
"description": knowledge_set.description,
"datasets": datasets,
"missing_dataset_ids": missing_dataset_ids,
}
)
@ -161,7 +177,7 @@ def soul_candidates(
lists = {
"dify_tools": dify_tools,
"cli_tools": cli_tools,
"knowledge_datasets": knowledge_datasets,
"knowledge_sets": knowledge_sets,
"human_contacts": human_contacts,
}
capped: dict[str, list[dict[str, Any]]] = {}

View File

@ -11,6 +11,8 @@ from libs.helper import to_timestamp
from models import Account
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@ -37,6 +39,10 @@ from services.agent.errors import (
AgentVersionNotFoundError,
InvalidComposerConfigError,
)
from services.agent.knowledge_datasets import (
get_tenant_knowledge_dataset_rows,
list_missing_tenant_knowledge_dataset_ids,
)
from services.agent.roster_service import AgentRosterService
from services.app_service import AppService, CreateAppParams
from services.entities.agent_entities import (
@ -92,24 +98,62 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non
class AgentComposerService:
@classmethod
def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]:
def load_workflow_composer(
cls, *, tenant_id: str, app_id: str, node_id: str, snapshot_id: str | None = None
) -> dict[str, Any]:
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
if not binding:
if snapshot_id:
raise AgentVersionNotFoundError()
return cls._empty_workflow_state(app_id=app_id, workflow_id=workflow.id, node_id=node_id)
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
version = cls._workflow_composer_version(
tenant_id=tenant_id,
binding=binding,
agent=agent,
snapshot_id=snapshot_id,
)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
@classmethod
def _workflow_composer_version(
cls,
*,
tenant_id: str,
binding: WorkflowAgentNodeBinding,
agent: Agent | None,
snapshot_id: str | None,
) -> AgentConfigSnapshot | None:
if snapshot_id:
if agent is None:
raise AgentVersionNotFoundError()
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT:
if agent.scope != AgentScope.ROSTER:
raise AgentVersionNotFoundError()
elif binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
if (
agent.scope != AgentScope.WORKFLOW_ONLY
or agent.app_id != binding.app_id
or agent.workflow_id != binding.workflow_id
or agent.workflow_node_id != binding.node_id
):
raise AgentVersionNotFoundError()
else:
raise AgentVersionNotFoundError()
return cls._require_version(tenant_id=tenant_id, agent_id=agent.id, version_id=snapshot_id)
version_id = (
agent.active_config_snapshot_id
if agent and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else binding.current_snapshot_id
)
version = cls._get_version_if_present(
return cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id if agent else None,
version_id=version_id,
)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
@classmethod
def save_workflow_composer(
@ -120,6 +164,7 @@ class AgentComposerService:
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
@ -259,31 +304,37 @@ class AgentComposerService:
@classmethod
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
agent = cls._require_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent)
@classmethod
def load_agent_composer(cls, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent)
@classmethod
def _load_agent_composer_for_agent(cls, *, tenant_id: str, agent: Agent) -> dict[str, Any]:
draft = cls._get_or_create_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
created_by=agent.updated_by or agent.created_by,
)
if not agent:
raise AgentNotFoundError()
version = cls._require_version(
version = cls._get_version_if_present(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
return {
"variant": ComposerVariant.AGENT_APP.value,
"agent": cls._serialize_agent(agent),
"active_config_snapshot": cls._serialize_version(version),
"agent_soul": version.config_snapshot_dict,
"save_options": [
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value,
],
"draft": cls._serialize_draft(draft),
"agent_soul": draft.config_snapshot_dict,
"save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
"app_id": agent.app_id,
"backing_app_id": agent.backing_app_id or agent.app_id,
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
"chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages",
}
@classmethod
@ -292,22 +343,17 @@ class AgentComposerService:
) -> dict[str, Any]:
if payload.variant != ComposerVariant.AGENT_APP:
raise ValueError("Agent App composer endpoint only accepts agent_app variant")
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION:
raise InvalidComposerConfigError(
"Agent App composer only saves the normal draft. Use the publish endpoint to create a version."
)
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
if not agent:
agent = Agent(
tenant_id=tenant_id,
@ -317,6 +363,7 @@ class AgentComposerService:
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
app_id=app_id,
backing_app_id=app_id,
status=AgentStatus.ACTIVE,
created_by=account_id,
updated_by=account_id,
@ -327,35 +374,54 @@ class AgentComposerService:
except IntegrityError as exc:
db.session.rollback()
raise AgentNameConflictError() from exc
return cls._save_agent_composer_for_agent(
tenant_id=tenant_id,
agent=agent,
account_id=account_id,
payload=payload,
)
if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id:
version = cls._create_config_version(
tenant_id=tenant_id,
agent_id=agent.id,
account_id=account_id,
agent_soul=payload.agent_soul,
operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION,
version_note=payload.version_note,
@classmethod
def save_agent_composer(
cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload
) -> dict[str, Any]:
if payload.variant != ComposerVariant.AGENT_APP:
raise ValueError("Agent composer endpoint only accepts agent_app variant")
if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION:
raise InvalidComposerConfigError(
"Agent composer only saves the normal draft. Use the publish endpoint to create a version."
)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
else:
current_snapshot = cls._require_version(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
version = cls._update_current_version(
current_snapshot=current_snapshot,
account_id=account_id,
agent_soul=payload.agent_soul,
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
version_note=payload.version_note,
)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
agent.updated_by = account_id
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
return cls._save_agent_composer_for_agent(
tenant_id=tenant_id,
agent=agent,
account_id=account_id,
payload=payload,
)
@classmethod
def _save_agent_composer_for_agent(
cls, *, tenant_id: str, agent: Agent, account_id: str, payload: ComposerSavePayload
) -> dict[str, Any]:
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
cls._save_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
agent_soul=payload.agent_soul,
account_id_for_audit=account_id,
)
agent.updated_by = account_id
db.session.commit()
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id)
state["validation"] = cls.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
@ -363,6 +429,158 @@ class AgentComposerService:
)
return state
@classmethod
def publish_agent_app_draft(
cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
) -> dict[str, Any]:
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP:
raise AgentNotFoundError()
draft = cls._get_or_create_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
created_by=account_id,
)
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
ComposerConfigValidator.validate_publish_payload(
ComposerSavePayload(
variant=ComposerVariant.AGENT_APP,
agent_soul=agent_soul,
save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
version_note=version_note,
)
)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul)
version = cls._create_config_version(
tenant_id=tenant_id,
agent_id=agent.id,
account_id=account_id,
agent_soul=agent_soul,
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
version_note=version_note,
previous_snapshot_id=agent.active_config_snapshot_id,
)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(agent_soul)
agent.updated_by = account_id
draft.base_snapshot_id = version.id
draft.updated_by = account_id
db.session.commit()
return {
"result": "success",
"active_config_snapshot_id": version.id,
"active_config_snapshot": cls._serialize_version(version),
"draft": cls._serialize_draft(draft),
}
@classmethod
def checkout_agent_app_build_draft(
cls, *, tenant_id: str, agent_id: str, account_id: str, force: bool = False
) -> dict[str, Any]:
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
normal_draft = cls._get_or_create_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
created_by=account_id,
)
build_draft = cls._get_agent_draft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
)
if build_draft is not None and not force:
return cls._serialize_build_draft_state(build_draft)
if build_draft is None:
build_draft = AgentConfigDraft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
draft_owner_key=account_id,
created_by=account_id,
)
db.session.add(build_draft)
build_draft.base_snapshot_id = normal_draft.base_snapshot_id
build_draft.config_snapshot = AgentSoulConfig.model_validate(normal_draft.config_snapshot_dict)
build_draft.updated_by = account_id
db.session.commit()
return cls._serialize_build_draft_state(build_draft)
@classmethod
def load_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
build_draft = cls._get_agent_draft(
tenant_id=tenant_id,
agent_id=agent_id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
)
if build_draft is None:
raise AgentVersionNotFoundError()
return cls._serialize_build_draft_state(build_draft)
@classmethod
def save_agent_app_build_draft(
cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload
) -> dict[str, Any]:
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
ComposerConfigValidator.validate_draft_save_payload(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
build_draft = cls._save_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
agent_soul=payload.agent_soul,
account_id_for_audit=account_id,
)
db.session.commit()
return cls._serialize_build_draft_state(build_draft)
@classmethod
def apply_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
build_draft = cls._get_agent_draft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
)
if build_draft is None:
raise AgentVersionNotFoundError()
normal_draft = cls._save_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
agent_soul=AgentSoulConfig.model_validate(build_draft.config_snapshot_dict),
account_id_for_audit=account_id,
base_snapshot_id=build_draft.base_snapshot_id,
)
db.session.delete(build_draft)
db.session.commit()
return {"result": "success", "draft": cls._serialize_draft(normal_draft)}
@classmethod
def discard_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
build_draft = cls._get_agent_draft(
tenant_id=tenant_id,
agent_id=agent_id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_id,
)
if build_draft is not None:
db.session.delete(build_draft)
db.session.commit()
return {"result": "success"}
@classmethod
def collect_validation_findings(
cls,
@ -372,19 +590,15 @@ class AgentComposerService:
agent_id: str | None = None,
) -> dict[str, Any]:
"""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()
if payload.agent_soul is not None:
mentioned_ids |= {
mention.ref_id
for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt)
if mention.kind == MentionKind.KNOWLEDGE
}
existing_dataset_ids: set[str] | None = None
if mentioned_ids:
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
existing_knowledge_set_ids = (
{knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets}
if payload.agent_soul is not None
else None
)
findings = ComposerConfigValidator.collect_soft_findings(
payload,
existing_knowledge_set_ids=existing_knowledge_set_ids,
)
if agent_id and payload.agent_soul is not None:
findings["warnings"].extend(
cls._drive_mention_findings(
@ -395,6 +609,24 @@ class AgentComposerService:
)
return findings
@classmethod
def validate_knowledge_datasets(cls, *, tenant_id: str, agent_soul: AgentSoulConfig | None) -> None:
"""Hard-validate tenant-scoped knowledge set datasets before saving.
DTO validators own set shape, duplicate set ids/names, and duplicate
dataset ids within one set. This service-level check owns database
existence and tenant ownership so invalid or cross-tenant datasets fail
before Agent Soul snapshots are persisted.
"""
if agent_soul is None:
return
missing_ids = list_missing_tenant_knowledge_dataset_ids(tenant_id=tenant_id, agent_soul=agent_soul)
if missing_ids:
raise InvalidComposerConfigError(
"knowledge_dataset_not_found: knowledge sets reference missing or out-of-scope datasets: "
+ ", ".join(missing_ids)
)
@classmethod
def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None:
"""The Agent App's bound roster agent id, if any (validate-endpoint context)."""
@ -509,7 +741,7 @@ class AgentComposerService:
soul_lists, soul_truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
)
truncated = truncated or soul_truncated
@ -529,14 +761,14 @@ class AgentComposerService:
return response.model_dump(mode="json")
@classmethod
def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]:
def get_agent_app_candidates(cls, *, tenant_id: str, agent_id: str, user_id: str) -> dict[str, Any]:
"""Slash-menu data source for the Agent App (Console) composer (ENG-615)."""
from services.agent.composer_candidates import soul_candidates
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
agent_soul = cls._load_agent_soul(tenant_id=tenant_id, agent_id=agent_id)
soul_lists, truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
)
response = ComposerCandidatesResponse(
@ -568,24 +800,18 @@ class AgentComposerService:
return cls._parse_soul_snapshot(version)
@classmethod
def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
def _load_agent_soul(cls, *, tenant_id: str, agent_id: str) -> AgentSoulConfig | None:
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=agent_id)
if agent is None:
return None
version = cls._get_version_if_present(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
draft = cls._get_or_create_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
created_by=agent.updated_by or agent.created_by,
)
return cls._parse_soul_snapshot(version)
return AgentSoulConfig.model_validate(draft.config_snapshot_dict)
@staticmethod
def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
@ -629,30 +855,6 @@ class AgentComposerService:
variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id)
return [(variable.name, variable.value_type.value) for variable in variables.variables]
@staticmethod
def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
"""Tenant-scoped dataset lookup tolerating malformed ids.
Mention ids come from user-editable prompt text; a non-UUID id can never
match a dataset row, so it is simply absent from the result (-> missing/
placeholder semantics) instead of breaking the UUID-typed query.
"""
from uuid import UUID
from services.dataset_service import DatasetService
valid_ids: list[str] = []
for dataset_id in dataset_ids:
try:
UUID(dataset_id)
except (ValueError, TypeError):
continue
valid_ids.append(dataset_id)
if not valid_ids:
return {}
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
return {str(row.id): row for row in rows}
@staticmethod
def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]:
"""Workspace Dify Plugin tools, same source as the tool selector.
@ -1028,6 +1230,15 @@ class AgentComposerService:
icon: str | None = None,
icon_background: str | None = None,
) -> Agent:
backing_app = AgentRosterService(db.session).create_hidden_backing_app_for_workflow_agent(
tenant_id=tenant_id,
account_id=account_id,
name=name or f"Workflow Agent {node_id}",
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
agent = Agent(
tenant_id=tenant_id,
name=name or f"Workflow Agent {node_id}",
@ -1040,6 +1251,7 @@ class AgentComposerService:
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
app_id=app_id,
backing_app_id=backing_app.id,
workflow_id=workflow_id,
workflow_node_id=node_id,
status=AgentStatus.ACTIVE,
@ -1285,6 +1497,143 @@ class AgentComposerService:
or 0
) + 1
@classmethod
def _get_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent | None:
return db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
@classmethod
def _require_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent:
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
if agent is None:
raise AgentNotFoundError()
return agent
@classmethod
def _get_agent_draft(
cls,
*,
tenant_id: str,
agent_id: str,
draft_type: AgentConfigDraftType,
account_id: str | None,
) -> AgentConfigDraft | None:
stmt = select(AgentConfigDraft).where(
AgentConfigDraft.tenant_id == tenant_id,
AgentConfigDraft.agent_id == agent_id,
AgentConfigDraft.draft_type == draft_type,
)
if draft_type == AgentConfigDraftType.DEBUG_BUILD:
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
else:
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
return db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
@classmethod
def _get_or_create_agent_draft(
cls,
*,
tenant_id: str,
agent: Agent,
draft_type: AgentConfigDraftType,
account_id: str | None,
created_by: str | None,
) -> AgentConfigDraft:
draft = cls._get_agent_draft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=draft_type,
account_id=account_id,
)
if draft is not None:
return draft
base_snapshot = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id,
version_id=agent.active_config_snapshot_id,
)
agent_soul = (
AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict)
if base_snapshot is not None
else AgentSoulConfig()
)
draft = AgentConfigDraft(
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=draft_type,
account_id=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
draft_owner_key=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD and account_id else "",
base_snapshot_id=base_snapshot.id if base_snapshot else None,
config_snapshot=agent_soul,
created_by=created_by,
updated_by=created_by,
)
db.session.add(draft)
db.session.flush()
return draft
@classmethod
def _save_agent_draft(
cls,
*,
tenant_id: str,
agent: Agent,
draft_type: AgentConfigDraftType,
account_id: str | None,
agent_soul: AgentSoulConfig,
account_id_for_audit: str,
base_snapshot_id: str | None = None,
) -> AgentConfigDraft:
draft = cls._get_or_create_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=draft_type,
account_id=account_id,
created_by=account_id_for_audit,
)
draft.config_snapshot = agent_soul
if base_snapshot_id is not None:
draft.base_snapshot_id = base_snapshot_id
elif draft.base_snapshot_id is None:
draft.base_snapshot_id = agent.active_config_snapshot_id
draft.updated_by = account_id_for_audit
db.session.flush()
return draft
@classmethod
def _serialize_draft(cls, draft: AgentConfigDraft | None) -> dict[str, Any] | None:
if draft is None:
return None
return {
"id": draft.id,
"agent_id": draft.agent_id,
"draft_type": draft.draft_type.value,
"account_id": draft.account_id,
"base_snapshot_id": draft.base_snapshot_id,
"created_by": draft.created_by,
"updated_by": draft.updated_by,
"created_at": to_timestamp(draft.created_at),
"updated_at": to_timestamp(draft.updated_at),
}
@classmethod
def _serialize_build_draft_state(cls, draft: AgentConfigDraft) -> dict[str, Any]:
return {
"variant": ComposerVariant.AGENT_APP.value,
"draft": cls._serialize_draft(draft),
"agent_soul": draft.config_snapshot_dict,
}
@classmethod
def _get_draft_workflow(cls, *, tenant_id: str, app_id: str) -> Workflow:
workflow = db.session.scalar(
@ -1471,6 +1820,12 @@ class AgentComposerService:
"impact_summary": cls.calculate_impact(tenant_id=binding.tenant_id, current_snapshot_id=version.id)
if version
else None,
"app_id": binding.app_id,
"backing_app_id": agent.backing_app_id if agent else None,
"hidden_app_backed": bool(agent and agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
"chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages" if agent else None,
"workflow_id": binding.workflow_id,
"node_id": binding.node_id,
}
@classmethod
@ -1479,7 +1834,15 @@ class AgentComposerService:
"id": agent.id,
"name": agent.name,
"description": agent.description,
"role": agent.role,
"icon_type": agent.icon_type,
"icon": agent.icon,
"icon_background": agent.icon_background,
"scope": agent.scope.value,
"source": agent.source.value,
"app_id": agent.app_id,
"backing_app_id": agent.backing_app_id or agent.app_id,
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
"status": agent.status.value,
"active_config_snapshot_id": agent.active_config_snapshot_id,
}

View File

@ -148,15 +148,15 @@ class ComposerConfigValidator:
cls,
payload: ComposerSavePayload,
*,
existing_dataset_ids: set[str] | None = None,
existing_knowledge_set_ids: set[str] | None = None,
) -> dict[str, Any]:
"""ENG-617 §5.3/§5.4 soft findings — never block save.
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge-set
mentions with a placeholder name (0522 consensus) instead of dropping or
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
deleted datasets surface as placeholders too.
rejecting them. With ``existing_knowledge_set_ids`` provided, mentions
that no longer exist in the current Agent Soul surface as placeholders too.
"""
warnings: list[dict[str, Any]] = []
placeholders: list[dict[str, str]] = []
@ -188,7 +188,7 @@ class ComposerConfigValidator:
resolved = resolver(mention)
if mention.kind == MentionKind.KNOWLEDGE:
dangling = resolved is None or (
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
existing_knowledge_set_ids is not None and mention.ref_id not in existing_knowledge_set_ids
)
if dangling:
placeholders.append(

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from typing import Any
from uuid import UUID
from models.agent_config_entities import AgentSoulConfig
def list_agent_soul_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
"""Return normalized unique knowledge dataset ids in config order.
Agent v2 knowledge dataset selection is owned by ``knowledge.sets``. This
helper keeps composer, workflow validation, candidates, and runtime
diagnostics aligned on the same normalization rules: strip whitespace, drop
blanks, preserve first-seen order, and deduplicate.
"""
dataset_ids: list[str] = []
seen: set[str] = set()
for knowledge_set in agent_soul.knowledge.sets:
for dataset in knowledge_set.datasets:
dataset_id = (dataset.id or "").strip()
if not dataset_id or dataset_id in seen:
continue
seen.add(dataset_id)
dataset_ids.append(dataset_id)
return dataset_ids
def get_tenant_knowledge_dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
"""Return tenant-scoped dataset rows for normalized knowledge dataset ids.
Knowledge ids come from user-editable config. Malformed ids can never match
a dataset row, so they are treated as missing instead of breaking the
UUID-typed dataset lookup.
"""
from services.dataset_service import DatasetService
valid_ids: list[str] = []
for dataset_id in dataset_ids:
try:
UUID(dataset_id)
except (TypeError, ValueError):
continue
valid_ids.append(dataset_id)
if not valid_ids:
return {}
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
return {str(row.id): row for row in rows}
def list_missing_tenant_knowledge_dataset_ids(*, tenant_id: str, agent_soul: AgentSoulConfig | None) -> list[str]:
"""Return normalized knowledge dataset ids missing from the tenant scope."""
if agent_soul is None:
return []
dataset_ids = list_agent_soul_knowledge_dataset_ids(agent_soul)
if not dataset_ids:
return []
rows = get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=dataset_ids)
return [dataset_id for dataset_id in dataset_ids if dataset_id not in rows]

View File

@ -6,7 +6,7 @@ Slash-menu insertions are stored inline in the plain-string prompt as tokens:
``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
``tools`` / ``knowledge.sets`` / ``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
@ -211,9 +211,9 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
if mention.ref_id in (cli_tool.id, cli_tool.name):
return cli_tool.name or cli_tool.id
case MentionKind.KNOWLEDGE:
for dataset in agent_soul.knowledge.datasets:
if mention.ref_id == dataset.id:
return dataset.name or dataset.id
for knowledge_set in agent_soul.knowledge.sets:
if mention.ref_id == knowledge_set.id:
return knowledge_set.name or knowledge_set.id
case MentionKind.HUMAN:
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
case _:

View File

@ -3,11 +3,14 @@ from typing import Any, TypedDict
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from constants.model_template import default_app_templates
from core.app.entities.app_invoke_entities import InvokeFrom
from libs.datetime_utils import naive_utc_now
from libs.helper import to_timestamp
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@ -21,7 +24,7 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import AppStatus, ConversationFromSource, ConversationStatus
from models.model import App, AppMode, Conversation, IconType
from models.model import App, AppMode, AppModelConfig, Conversation, IconType
from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
@ -98,6 +101,8 @@ class AgentRosterService:
"scope": agent.scope.value,
"source": agent.source.value,
"app_id": agent.app_id,
"backing_app_id": agent.backing_app_id,
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
"debug_conversation_id": None,
"workflow_id": agent.workflow_id,
"workflow_node_id": agent.workflow_node_id,
@ -363,6 +368,7 @@ class AgentRosterService:
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
app_id=app_id,
backing_app_id=app_id,
created_by=account_id,
updated_by=account_id,
)
@ -398,6 +404,53 @@ class AgentRosterService:
self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id)
return agent
def create_hidden_backing_app_for_workflow_agent(
self,
*,
tenant_id: str,
account_id: str | None,
name: str,
description: str = "",
icon_type: Any = None,
icon: str | None = None,
icon_background: str | None = None,
) -> App:
"""Create an internal Agent App used only to back a workflow-only Agent.
This deliberately bypasses AppService.create_app because that public
creation path also creates a roster Agent. Inline Agents need App runtime
infrastructure for chat/logs/monitoring, but must stay hidden from the
workspace Agent Roster until explicitly saved to roster.
"""
app_template = dict(default_app_templates[AppMode.AGENT]["app"])
app = App(**app_template)
app.name = name
app.description = description or ""
app.mode = AppMode.AGENT
normalized_icon_type = self._normalize_app_icon_type(icon_type)
app.icon_type = IconType(normalized_icon_type) if normalized_icon_type else IconType.EMOJI
app.icon = icon
app.icon_background = icon_background
app.tenant_id = tenant_id
app.enable_site = False
app.enable_api = False
app.api_rph = 0
app.api_rpm = 0
app.max_active_requests = None
app.created_by = account_id
app.maintainer = account_id
app.updated_by = account_id
self._session.add(app)
self._session.flush()
app_model_config = AppModelConfig(app_id=app.id, created_by=account_id, updated_by=account_id)
self._session.add(app_model_config)
self._session.flush()
app.app_model_config_id = app_model_config.id
self._session.flush()
return app
def _create_agent_app_debug_conversation(self, *, app_id: str, account_id: str) -> str:
"""Create one console debug conversation for an Agent App editor."""
@ -423,8 +476,31 @@ class AgentRosterService:
self._session.flush()
return conversation.id
@staticmethod
def runtime_backing_app_id(agent: Agent) -> str | None:
"""Return the App id that backs Agent runtime chat/log/monitoring."""
return agent.backing_app_id or agent.app_id
def _ensure_workflow_agent_backing_app(self, *, agent: Agent, account_id: str | None) -> str | None:
if agent.scope != AgentScope.WORKFLOW_ONLY or agent.backing_app_id:
return self.runtime_backing_app_id(agent)
backing_app = self.create_hidden_backing_app_for_workflow_agent(
tenant_id=agent.tenant_id,
account_id=account_id or agent.updated_by or agent.created_by,
name=agent.name,
description=agent.description,
icon_type=agent.icon_type,
icon=agent.icon,
icon_background=agent.icon_background,
)
agent.backing_app_id = backing_app.id
self._session.flush()
return backing_app.id
def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str:
if not agent.app_id:
backing_app_id = self._ensure_workflow_agent_backing_app(agent=agent, account_id=account_id)
if not backing_app_id:
raise AgentNotFoundError()
mapping = self._session.scalar(
@ -438,7 +514,7 @@ class AgentRosterService:
conversation_id = self._session.scalar(
select(Conversation.id).where(
Conversation.id == mapping.conversation_id,
Conversation.app_id == agent.app_id,
Conversation.app_id == backing_app_id,
Conversation.from_source == ConversationFromSource.CONSOLE,
Conversation.from_account_id == account_id,
Conversation.is_deleted.is_(False),
@ -448,21 +524,22 @@ class AgentRosterService:
return conversation_id
mapping.conversation_id = self._create_agent_app_debug_conversation(
app_id=agent.app_id,
app_id=backing_app_id,
account_id=account_id,
)
mapping.app_id = backing_app_id
self._session.flush()
return mapping.conversation_id
conversation_id = self._create_agent_app_debug_conversation(
app_id=agent.app_id,
app_id=backing_app_id,
account_id=account_id,
)
self._session.add(
AgentDebugConversation(
tenant_id=agent.tenant_id,
agent_id=agent.id,
app_id=agent.app_id,
app_id=backing_app_id,
account_id=account_id,
conversation_id=conversation_id,
)
@ -479,8 +556,6 @@ class AgentRosterService:
select(Agent).where(
Agent.tenant_id == tenant_id,
Agent.id == agent_id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
)
@ -501,16 +576,20 @@ class AgentRosterService:
select(Agent).where(
Agent.tenant_id == tenant_id,
Agent.id == agent_id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
)
if agent is None or not agent.app_id:
if agent is None:
raise AgentNotFoundError()
backing_app_id = self._ensure_workflow_agent_backing_app(
agent=agent,
account_id=agent.updated_by or agent.created_by,
)
if not backing_app_id:
raise AgentNotFoundError()
conversation_id = self._create_agent_app_debug_conversation(
app_id=agent.app_id,
app_id=backing_app_id,
account_id=account_id,
)
mapping = self._session.scalar(
@ -525,13 +604,13 @@ class AgentRosterService:
AgentDebugConversation(
tenant_id=tenant_id,
agent_id=agent_id,
app_id=agent.app_id,
app_id=backing_app_id,
account_id=account_id,
conversation_id=conversation_id,
)
)
else:
mapping.app_id = agent.app_id
mapping.app_id = backing_app_id
mapping.conversation_id = conversation_id
self._session.flush()
if commit:
@ -546,11 +625,7 @@ class AgentRosterService:
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
):
if agent.tenant_id != tenant_id or agent.status != AgentStatus.ACTIVE:
continue
conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation(
agent=agent,
@ -574,7 +649,7 @@ class AgentRosterService:
Agent.status == AgentStatus.ACTIVE,
)
).all()
return {agent.app_id: agent for agent in agents if agent.app_id}
return {agent.app_id: agent for agent in agents if agent.app_id and agent.id}
def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None:
"""Return the roster Agent that backs the given Agent App, if any."""
@ -625,6 +700,59 @@ class AgentRosterService:
raise AgentNotFoundError()
return app
def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App:
"""Resolve the App that backs an Agent runtime surface.
Roster Agents use their public Agent App. Workflow-only Agents use a
hidden Agent App stored in ``backing_app_id`` so console chat/logs can
reuse the app runtime without exposing the resource in workspace app
lists.
"""
agent = self._session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.id == agent_id,
Agent.status == AgentStatus.ACTIVE,
or_(
and_(Agent.scope == AgentScope.ROSTER, Agent.source == AgentSource.AGENT_APP),
and_(
Agent.scope == AgentScope.WORKFLOW_ONLY,
Agent.source == AgentSource.WORKFLOW,
Agent.workflow_id.is_not(None),
Agent.workflow_node_id.is_not(None),
),
),
)
.limit(1)
)
if agent is None:
raise AgentNotFoundError()
should_commit_backing_app = agent.scope == AgentScope.WORKFLOW_ONLY and not agent.backing_app_id
backing_app_id = self._ensure_workflow_agent_backing_app(
agent=agent,
account_id=agent.updated_by or agent.created_by,
)
if not backing_app_id:
raise AgentNotFoundError()
if should_commit_backing_app:
self._session.commit()
app = self._session.scalar(
select(App)
.where(
App.tenant_id == tenant_id,
App.id == backing_app_id,
App.mode == AppMode.AGENT,
App.status == AppStatus.NORMAL,
)
.limit(1)
)
if app is None:
raise AgentNotFoundError()
return app
def duplicate_agent_app(
self,
*,
@ -692,10 +820,10 @@ class AgentRosterService:
return target_app
@staticmethod
def _normalize_app_icon_type(icon_type: IconType | str | None) -> str | None:
def _normalize_app_icon_type(icon_type: Any | None) -> str | None:
if icon_type is None:
return None
if isinstance(icon_type, IconType):
if isinstance(icon_type, IconType) or hasattr(icon_type, "value"):
return icon_type.value
return icon_type
@ -836,6 +964,7 @@ class AgentRosterService:
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
if agent.source == AgentSource.AGENT_APP:
return {
AgentConfigRevisionOperation.PUBLISH_DRAFT,
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
AgentConfigRevisionOperation.RESTORE_VERSION,
@ -849,16 +978,37 @@ class AgentRosterService:
}
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
"""Return whether the Agent's current active snapshot is a visible published version."""
"""Return whether the editable draft matches the active published snapshot."""
return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get(
agent.id,
False,
)
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
"""Return publish-state flags for the active config snapshots of the given Agents."""
"""Return whether each Agent's normal draft is aligned with its active published snapshot."""
agents = [agent for agent in agents if agent.id]
if not agents:
return {}
published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents)
return {agent.id: agent.id in published_agent_ids for agent in agents}
drafts = self._session.scalars(
select(AgentConfigDraft).where(
AgentConfigDraft.tenant_id == tenant_id,
AgentConfigDraft.agent_id.in_([agent.id for agent in agents]),
AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT,
AgentConfigDraft.account_id.is_(None),
)
).all()
drafts_by_agent_id = {draft.agent_id: draft for draft in drafts}
result: dict[str, bool] = {}
for agent in agents:
draft = drafts_by_agent_id.get(agent.id)
result[agent.id] = (
agent.id in published_agent_ids
and bool(agent.active_config_snapshot_id)
and (draft is None or draft.base_snapshot_id == agent.active_config_snapshot_id)
)
return result
def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
@ -957,26 +1107,37 @@ class AgentRosterService:
raise AgentVersionNotFoundError()
version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
if agent.active_config_snapshot_id == version.id:
return {"result": "success", "active_config_snapshot_id": version.id}
previous_snapshot_id = agent.active_config_snapshot_id
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(version.config_snapshot)
agent.updated_by = account_id
self._session.add(
AgentConfigRevision(
draft = self._session.scalar(
select(AgentConfigDraft)
.where(
AgentConfigDraft.tenant_id == tenant_id,
AgentConfigDraft.agent_id == agent_id,
AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT,
AgentConfigDraft.account_id.is_(None),
)
.limit(1)
)
if draft is None:
draft = AgentConfigDraft(
tenant_id=tenant_id,
agent_id=agent_id,
previous_snapshot_id=previous_snapshot_id,
current_snapshot_id=version.id,
revision=self._next_revision(tenant_id=tenant_id, agent_id=agent_id),
operation=AgentConfigRevisionOperation.RESTORE_VERSION,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft_owner_key="",
created_by=account_id,
)
)
self._session.add(draft)
draft.base_snapshot_id = version.id
draft.config_snapshot = AgentSoulConfig.model_validate(version.config_snapshot_dict)
draft.updated_by = account_id
agent.updated_by = account_id
self._session.commit()
return {"result": "success", "active_config_snapshot_id": version.id}
return {
"result": "success",
"active_config_snapshot_id": agent.active_config_snapshot_id or version.id,
"draft_config_id": draft.id,
"restored_version_id": version.id,
}
def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent:
stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id)

View File

@ -1,15 +1,17 @@
"""Validate + extract metadata from an uploaded Skill package (ENG-370).
"""Validate and normalize uploaded Skill packages for drive standardization.
A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entry
file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``,
followed by markdown instructions). This service validates the archive (extension,
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields) and
extracts a manifest consumed by drive standardization.
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields),
normalizes retained member paths relative to the selected skill root, rebuilds
canonical archive bytes, and returns normalized metadata together with the
archive-root ``SKILL.md`` bytes.
It does NOT execute or load the skill the agent backend owns execution. It also
does not persist anything into Agent Soul or bind anything to config versions;
``SkillStandardizeService`` consumes the manifest and commits the canonical drive
rows instead.
``SkillStandardizeService`` consumes the normalized package and commits the
canonical drive rows instead.
"""
from __future__ import annotations
@ -19,6 +21,7 @@ import io
import posixpath
import re
import zipfile
import zlib
import yaml
from pydantic import BaseModel
@ -58,10 +61,69 @@ class SkillManifest(BaseModel):
hash: str # sha256 of the archive bytes
class SkillPackageService:
"""Validate Skill archives and extract their manifest."""
class NormalizedSkillPackage(BaseModel):
"""Canonical skill package bytes and metadata ready to store in agent drive."""
def validate_and_extract(self, *, content: bytes, filename: str) -> SkillManifest:
manifest: SkillManifest
archive_bytes: bytes
skill_md_bytes: bytes
strip_prefix: str | None
class SkillPackageService:
"""Validate Skill archives and produce the normalized package stored in drive."""
def validate_and_normalize(self, *, content: bytes, filename: str) -> NormalizedSkillPackage:
"""Return the canonical drive package for an uploaded skill archive.
The shallowest ``SKILL.md`` defines the skill root. When exactly one
depth-2 ``<folder>/SKILL.md`` exists, normalization strips that top-level
folder and silently discards all members outside it, including nested
foreign paths. When that unique depth-2 condition does not apply, files
outside the selected skill root still raise ``files_outside_skill_root``.
The returned manifest is normalized to archive-root ``SKILL.md`` and its
hash describes the rebuilt archive bytes. Member read/decompression
failures while consuming the archive are mapped to ``invalid_archive``.
"""
archive = self._open_archive(content=content, filename=filename)
with archive:
members = self._collect_file_members(archive)
member_paths = [safe_path for _, safe_path in members]
entry_path = self._find_skill_md(member_paths)
strip_prefix = self._skill_root_prefix(entry_path)
normalized_members = self._normalize_members(
members=members,
skill_root_prefix=strip_prefix,
ignore_outside_selected_root=self._can_strip_single_top_level_folder(
paths=member_paths, entry_path=entry_path
),
)
skill_md_member = normalized_members[_SKILL_MD_NAME]
self._validate_skill_md_size(skill_md_member)
skill_md_bytes = self._read_member_bytes_from_archive(archive, member_info=skill_md_member)
skill_md = self._decode_skill_md(skill_md_bytes)
normalized_archive_bytes = self._build_normalized_archive(
archive=archive, normalized_members=normalized_members
)
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
name, description = self._parse_skill_md(skill_md)
manifest = SkillManifest(
name=name,
description=description,
entry_path=_SKILL_MD_NAME,
files=sorted(normalized_members),
size=normalized_size,
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
)
return NormalizedSkillPackage(
manifest=manifest,
archive_bytes=normalized_archive_bytes,
skill_md_bytes=skill_md_bytes,
strip_prefix=strip_prefix,
)
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
self._check_extension(filename)
if not content:
raise SkillPackageError("empty_archive", "skill archive is empty", status_code=400)
@ -69,52 +131,90 @@ class SkillPackageService:
raise SkillPackageError("archive_too_large", "skill archive exceeds size limit", status_code=400)
try:
archive = zipfile.ZipFile(io.BytesIO(content))
return zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile as exc:
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
with archive:
infos = [info for info in archive.infolist() if not info.is_dir()]
if len(infos) > _MAX_ENTRIES:
raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400)
def _collect_file_members(self, archive: zipfile.ZipFile) -> list[tuple[zipfile.ZipInfo, str]]:
infos = [info for info in archive.infolist() if not info.is_dir()]
if len(infos) > _MAX_ENTRIES:
raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400)
safe_paths: list[str] = []
total_uncompressed = 0
for info in infos:
safe_paths.append(self._safe_member_path(info.filename))
total_uncompressed += max(info.file_size, 0)
if total_uncompressed > _MAX_UNCOMPRESSED_BYTES:
raise SkillPackageError(
"archive_too_large", "skill archive uncompressed size exceeds limit", status_code=400
)
entry_path = self._find_skill_md(safe_paths)
skill_md = self._read_skill_md(archive, entry_path)
name, description = self._parse_skill_md(skill_md)
return SkillManifest(
name=name,
description=description,
entry_path=entry_path,
files=sorted(safe_paths),
size=total_uncompressed,
hash=hashlib.sha256(content).hexdigest(),
)
def read_member_bytes(self, *, content: bytes, member_path: str) -> bytes:
"""Read a single archive member's bytes (used by standardization, ENG-594)."""
try:
archive = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile as exc:
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
with archive:
member = next(
(info for info in archive.infolist() if posixpath.normpath(info.filename) == member_path),
None,
members: list[tuple[zipfile.ZipInfo, str]] = []
total_uncompressed = 0
for info in infos:
members.append((info, self._safe_member_path(info.filename)))
total_uncompressed += max(info.file_size, 0)
if total_uncompressed > _MAX_UNCOMPRESSED_BYTES:
raise SkillPackageError(
"archive_too_large",
"skill archive uncompressed size exceeds limit",
status_code=400,
)
if member is None:
raise SkillPackageError("member_not_found", f"{member_path} not found in archive", status_code=400)
return archive.read(member)
return members
@staticmethod
def _skill_root_prefix(entry_path: str) -> str | None:
skill_root = posixpath.dirname(entry_path)
if not skill_root:
return None
return f"{skill_root}/"
def _normalize_members(
self,
*,
members: list[tuple[zipfile.ZipInfo, str]],
skill_root_prefix: str | None,
ignore_outside_selected_root: bool = False,
) -> dict[str, zipfile.ZipInfo]:
normalized_members: dict[str, zipfile.ZipInfo] = {}
for info, safe_path in members:
if skill_root_prefix is not None:
if not safe_path.startswith(skill_root_prefix):
if ignore_outside_selected_root:
continue
raise SkillPackageError(
"files_outside_skill_root",
"skill archive contains files outside the selected skill root",
status_code=400,
)
normalized_path = safe_path.removeprefix(skill_root_prefix)
else:
normalized_path = safe_path
if (
not normalized_path
or normalized_path in {".", ".."}
or normalized_path.startswith("/")
or "\\" in normalized_path
):
raise SkillPackageError("unsafe_path", "skill archive contains an unsafe path", status_code=400)
if normalized_path in normalized_members:
raise SkillPackageError(
"duplicate_member_path",
"skill archive contains duplicate normalized paths",
status_code=400,
)
normalized_members[normalized_path] = info
if _SKILL_MD_NAME not in normalized_members:
raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400)
return normalized_members
def _build_normalized_archive(
self,
*,
archive: zipfile.ZipFile,
normalized_members: dict[str, zipfile.ZipInfo],
) -> bytes:
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as normalized_archive:
for normalized_path in sorted(normalized_members):
normalized_archive.writestr(
normalized_path,
self._read_member_bytes_from_archive(archive, member_info=normalized_members[normalized_path]),
)
return output.getvalue()
@staticmethod
def _check_extension(filename: str) -> None:
@ -145,17 +245,26 @@ class SkillPackageService:
return min(candidates, key=lambda p: (p.count("/"), len(p)))
@staticmethod
def _read_skill_md(archive: zipfile.ZipFile, entry_path: str) -> str:
# Look the member up by its original name (normpath may differ from the stored name).
member = next(
(info for info in archive.infolist() if posixpath.normpath(info.filename) == entry_path),
None,
)
if member is None:
raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400)
if member.file_size > _MAX_SKILL_MD_BYTES:
def _can_strip_single_top_level_folder(*, paths: list[str], entry_path: str) -> bool:
if entry_path.count("/") != 1:
return False
candidates = [path for path in paths if path.count("/") == 1 and posixpath.basename(path) == _SKILL_MD_NAME]
return len(candidates) == 1 and candidates[0] == entry_path
@staticmethod
def _read_member_bytes_from_archive(archive: zipfile.ZipFile, *, member_info: zipfile.ZipInfo) -> bytes:
try:
return archive.read(member_info)
except (zipfile.BadZipFile, EOFError, OSError, RuntimeError, ValueError, zlib.error) as exc:
raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc
@staticmethod
def _validate_skill_md_size(member_info: zipfile.ZipInfo) -> None:
if member_info.file_size > _MAX_SKILL_MD_BYTES:
raise SkillPackageError("skill_md_too_large", "SKILL.md exceeds size limit", status_code=400)
raw = archive.read(member)
@staticmethod
def _decode_skill_md(raw: bytes) -> str:
try:
return raw.decode("utf-8")
except UnicodeDecodeError as exc:
@ -193,4 +302,4 @@ class SkillPackageService:
return loaded if isinstance(loaded, dict) else {}
__all__ = ["SkillManifest", "SkillPackageError", "SkillPackageService"]
__all__ = ["NormalizedSkillPackage", "SkillManifest", "SkillPackageError", "SkillPackageService"]

View File

@ -7,19 +7,14 @@ to the agent drive (Agent Files §5.4 / §4):
* ``<slug>/.DIFY-SKILL-FULL.zip`` the full archive, kept only to restore the
complete skill contents.
Both are stored as ``ToolFile`` records and bound via ``AgentDriveService.commit``
with ``value_owned_by_drive=True`` (the drive owns their lifecycle). The returned
payload is the slim drive-derived skill DTO the UI needs to work with the drive
catalog ``name``, ``description``, ``path``, ``skill_md_key``, and
``archive_key`` plus the extracted manifest for upload feedback. The console
``/skills/upload`` endpoints delegate to this service so "upload" now always means
drive-backed skill normalization rather than Agent Soul binding.
The archive's member list is stored in skill metadata and resolved lazily for
inspect/preview/runtime. Upload must not eagerly materialize every archive member
as a separate ToolFile; small archives with many files would otherwise perform
hundreds of storage writes and DB commits inside the request.
"""
from __future__ import annotations
import mimetypes
import posixpath
import re
from typing import Any
@ -38,7 +33,11 @@ def slugify_skill_name(name: str) -> str:
class SkillStandardizeService:
"""Validate + standardize a Skill package into a per-agent drive upload result."""
"""Persist a normalized skill package into drive-owned files for one agent.
Instances are intentionally stateful: ``standardize()`` updates
``last_committed_items`` with the drive commit result for the most recent call.
"""
def __init__(
self,
@ -50,6 +49,7 @@ class SkillStandardizeService:
self._package = package_service or SkillPackageService()
self._drive = drive_service or AgentDriveService()
self._tool_files = tool_file_manager or ToolFileManager()
self.last_committed_items: list[dict[str, Any]] = []
def standardize(
self,
@ -60,17 +60,23 @@ class SkillStandardizeService:
user_id: str,
agent_id: str,
) -> dict[str, Any]:
manifest = self._package.validate_and_extract(content=content, filename=filename)
skill_md_bytes = self._package.read_member_bytes(content=content, member_path=manifest.entry_path)
"""Create two ToolFiles, commit two drive-owned keys, and return skill metadata.
This writes ``<slug>/SKILL.md`` and ``<slug>/.DIFY-SKILL-FULL.zip``,
stores the drive commit rows in ``last_committed_items``, and returns the
console response shape ``{"skill": ..., "manifest": ...}``.
"""
package = self._package.validate_and_normalize(content=content, filename=filename)
manifest = package.manifest
slug = slugify_skill_name(manifest.name)
# Drive-owned files: canonical SKILL.md, every inspectable archive file,
# and the full archive for future restore/export.
# Drive-owned files: canonical SKILL.md and the full archive. The
# archive member tree is preserved in metadata and resolved lazily.
md_tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=skill_md_bytes,
file_binary=package.skill_md_bytes,
mimetype="text/markdown",
filename=_SKILL_MD_NAME,
)
@ -78,38 +84,14 @@ class SkillStandardizeService:
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=content,
file_binary=package.archive_bytes,
mimetype="application/zip",
filename=_FULL_ARCHIVE_NAME,
)
skill_md_key = f"{slug}/{_SKILL_MD_NAME}"
archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}"
member_items: list[DriveCommitItem] = []
for member_path in sorted(set(manifest.files)):
member_key = f"{slug}/{member_path}"
if member_key in {skill_md_key, archive_key}:
continue
member_bytes = self._package.read_member_bytes(content=content, member_path=member_path)
mimetype = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
member_tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=member_bytes,
mimetype=mimetype,
filename=posixpath.basename(member_path),
)
member_items.append(
DriveCommitItem(
key=member_key,
file_ref=DriveFileRef(kind="tool_file", id=member_tool_file.id),
value_owned_by_drive=True,
)
)
self._drive.commit(
committed_items = self._drive.commit(
tenant_id=tenant_id,
user_id=user_id,
agent_id=agent_id,
@ -130,23 +112,17 @@ class SkillStandardizeService:
file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id),
value_owned_by_drive=True,
),
*member_items,
],
)
drive_skill = next(
skill
for skill in self._drive.list_skills(tenant_id=tenant_id, agent_id=agent_id)
if skill["skill_md_key"] == skill_md_key
)
self.last_committed_items = committed_items
return {
"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"],
"name": manifest.name,
"description": manifest.description,
"path": slug,
"skill_md_key": skill_md_key,
"archive_key": archive_key,
},
"manifest": manifest.model_dump(),
}

View File

@ -39,10 +39,10 @@ class WorkflowAgentPublishService:
@classmethod
def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]:
"""Return draft graph with persisted Agent node job config projected into node data.
"""Return draft graph with persisted Agent binding fields projected into node data.
Workflow draft graph is the front-end's editing source of truth, while
runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This
runtime/publish reads WorkflowAgentNodeBinding. This
response-only projection keeps reads aligned without writing binding
details back into the stored graph JSON.
"""
@ -64,6 +64,18 @@ class WorkflowAgentPublishService:
node_data = agent_nodes.get(binding.node_id)
if not isinstance(node_data, dict):
continue
graph_binding = node_data.get(cls._AGENT_BINDING_KEY)
is_pending_inline_graph_binding = (
isinstance(graph_binding, Mapping)
and graph_binding.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value
and (not graph_binding.get("agent_id") or not graph_binding.get("current_snapshot_id"))
)
if not is_pending_inline_graph_binding or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
node_data[cls._AGENT_BINDING_KEY] = {
"binding_type": binding.binding_type.value,
"agent_id": binding.agent_id,
"current_snapshot_id": binding.current_snapshot_id,
}
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
if node_job.workflow_prompt is not None:
node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt
@ -231,6 +243,10 @@ class WorkflowAgentPublishService:
continue
if not isinstance(binding_payload, Mapping):
raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.")
if binding_payload.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value and (
not binding_payload.get("agent_id") or not binding_payload.get("current_snapshot_id")
):
continue
cls._sync_agent_binding_for_node(
session=session,
draft_workflow=draft_workflow,

View File

@ -19,10 +19,18 @@ ToolFile records (see ``AgentDriveFile``). This service is the control plane:
from __future__ import annotations
import base64
import hashlib
import hmac
import io
import json
import logging
import mimetypes
import os
import re
import time
import urllib.parse
import zipfile
from typing import Any, Literal, TypedDict
from urllib.parse import unquote
@ -31,6 +39,7 @@ from sqlalchemy import func, select
from sqlalchemy.exc import DataError, SQLAlchemyError
from sqlalchemy.orm import Session
from configs import dify_config
from core.app.file_access.controller import DatabaseFileAccessController
from core.db.session_factory import session_factory
from extensions.ext_storage import storage
@ -46,6 +55,7 @@ _MAX_KEY_LENGTH = 512
_DRIVE_REF_PREFIX = "agent-"
_SKILL_MD_SUFFIX = "/SKILL.md"
_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE = "agent-drive-archive-member"
class AgentDriveError(Exception):
@ -365,6 +375,7 @@ class AgentDriveService:
skill_md_key=skill_md_key,
manifest_files=manifest_files,
drive_keys=drive_keys,
archive_available=catalog["archive_key"] in drive_keys if catalog["archive_key"] else False,
)
return {
**catalog,
@ -598,6 +609,7 @@ class AgentDriveService:
skill_md_key: str,
manifest_files: list[str] | None,
drive_keys: set[str],
archive_available: bool = False,
) -> tuple[list[AgentDriveSkillFileInfo], list[str]]:
warnings: list[str] = []
if manifest_files:
@ -617,13 +629,14 @@ class AgentDriveService:
if path == _SKILL_ARCHIVE_NAME:
continue
drive_key = f"{skill_path}/{path}"
available_in_drive = drive_key in drive_keys or (archive_available and path != _SKILL_ARCHIVE_NAME)
files.append(
{
"path": path,
"name": path.rsplit("/", 1)[-1],
"type": "file",
"drive_key": drive_key if drive_key in drive_keys else None,
"available_in_drive": drive_key in drive_keys,
"drive_key": drive_key if available_in_drive else None,
"available_in_drive": available_in_drive,
}
)
if "SKILL.md" not in {file["path"] for file in files}:
@ -844,56 +857,209 @@ class AgentDriveService:
return row
def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str:
if row.file_kind == AgentDriveFileKind.TOOL_FILE:
tool_file = session.scalar(
select(ToolFile).where(ToolFile.id == row.file_id, ToolFile.tenant_id == tenant_id)
)
return self._storage_key_for_ref(
session,
tenant_id=tenant_id,
file_kind=row.file_kind,
file_id=row.file_id,
)
def _storage_key_for_ref(
self,
session: Session,
*,
tenant_id: str,
file_kind: AgentDriveFileKind,
file_id: str,
) -> str:
if file_kind == AgentDriveFileKind.TOOL_FILE:
tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id))
if tool_file is None:
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
return tool_file.file_key
upload_file = session.scalar(
select(UploadFile).where(UploadFile.id == row.file_id, UploadFile.tenant_id == tenant_id)
select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id)
)
if upload_file is None:
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
return upload_file.key
def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
"""Truncated text preview of one drive value (binary-safe, never 500s on size)."""
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
size = row.size
def _archive_member_for_key(
self,
session: Session,
*,
tenant_id: str,
agent_id: str,
key: str,
) -> tuple[AgentDriveFile, str]:
normalized_key = normalize_drive_key(key)
if "/" not in normalized_key:
raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
skill_path, member_path = normalized_key.split("/", 1)
if member_path in {_SKILL_ARCHIVE_NAME, ""}:
raise AgentDriveError("drive_key_not_found", "no archive member for this key", status_code=404)
data = bytearray()
for chunk in storage.load_stream(storage_key):
data.extend(chunk)
if len(data) > self.PREVIEW_MAX_BYTES:
break
truncated = len(data) > self.PREVIEW_MAX_BYTES
sample = bytes(data[: self.PREVIEW_MAX_BYTES])
# Same semantics as the sandbox read endpoint: NUL or undecodable -> binary.
skill_md_key = f"{skill_path}{_SKILL_MD_SUFFIX}"
skill_row = session.scalar(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key == skill_md_key,
AgentDriveFile.is_skill.is_(True),
)
)
if skill_row is None:
raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
metadata = self._parse_skill_metadata(skill_row.key, skill_row.skill_metadata)
manifest_files = {normalize_drive_key(path) for path in (metadata.manifest_files or [])}
if member_path not in manifest_files:
raise AgentDriveError("drive_key_not_found", "archive member is not part of this skill", status_code=404)
archive_row = session.scalar(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key == self._skill_archive_key(skill_md_key),
)
)
if archive_row is None:
raise AgentDriveError("drive_key_not_found", "skill archive is missing", status_code=404)
return archive_row, member_path
def _load_archive_member_bytes(
self,
*,
tenant_id: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
) -> bytes:
member_path = normalize_drive_key(member_path)
with session_factory.create_session() as session:
storage_key = self._storage_key_for_ref(
session,
tenant_id=tenant_id,
file_kind=archive_file_kind,
file_id=archive_file_id,
)
archive_bytes = b"".join(storage.load_stream(storage_key))
try:
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
member = next(
(
info
for info in archive.infolist()
if not info.is_dir() and normalize_drive_key(info.filename) == member_path
),
None,
)
if member is None:
raise AgentDriveError(
"drive_key_not_found", "archive member is missing from the skill archive", status_code=404
)
return archive.read(member)
except zipfile.BadZipFile as exc:
raise AgentDriveError("invalid_skill_archive", "skill archive is not a valid zip", status_code=500) from exc
@classmethod
def _preview_bytes(cls, *, key: str, size: int | None, payload: bytes) -> dict[str, Any]:
truncated = len(payload) > cls.PREVIEW_MAX_BYTES
sample = payload[: cls.PREVIEW_MAX_BYTES]
if b"\x00" in sample:
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
try:
text = sample.decode("utf-8")
except UnicodeDecodeError:
if truncated:
# A multi-byte char may sit on the cut point; retry without the tail.
try:
text = sample[:-3].decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
else:
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text}
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": key, "size": size, "truncated": truncated, "binary": False, "text": text}
def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
"""Truncated text preview of one drive value (binary-safe, never 500s on size)."""
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
try:
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
size = row.size
response_key = row.key
archive_ref: tuple[AgentDriveFile, str] | None = None
except AgentDriveError:
archive_ref = self._archive_member_for_key(
session,
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
)
storage_key = None
size = None
response_key = normalize_drive_key(key)
if archive_ref is not None:
archive_row, member_path = archive_ref
payload = self._load_archive_member_bytes(
tenant_id=tenant_id,
archive_file_kind=archive_row.file_kind,
archive_file_id=archive_row.file_id,
member_path=member_path,
)
return self._preview_bytes(key=response_key, size=len(payload), payload=payload)
data = bytearray()
assert storage_key is not None
for chunk in storage.load_stream(storage_key):
data.extend(chunk)
if len(data) > self.PREVIEW_MAX_BYTES:
break
return self._preview_bytes(key=response_key, size=size, payload=bytes(data))
def preview_archive_member_for_ref(
self,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
) -> dict[str, Any]:
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
payload = self._load_archive_member_bytes(
tenant_id=tenant_id,
archive_file_kind=archive_file_kind,
archive_file_id=archive_file_id,
member_path=member_path,
)
return self._preview_bytes(key=normalize_drive_key(key), size=len(payload), payload=payload)
def download_url(self, *, tenant_id: str, agent_id: str, key: str) -> str:
"""External signed URL for a browser download of one drive value."""
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
try:
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
except AgentDriveError:
archive_row, member_path = self._archive_member_for_key(
session,
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
)
return self.sign_archive_member_url(
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
archive_file_kind=archive_row.file_kind,
archive_file_id=archive_row.file_id,
member_path=member_path,
for_external=True,
as_attachment=True,
)
url = self._resolve_download_url(
tenant_id=tenant_id,
file_kind=row.file_kind,
@ -905,6 +1071,159 @@ class AgentDriveService:
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
return url
def download_url_archive_member_for_ref(
self,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
for_external: bool = True,
) -> str:
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
return self.sign_archive_member_url(
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
archive_file_kind=archive_file_kind,
archive_file_id=archive_file_id,
member_path=member_path,
for_external=for_external,
as_attachment=True,
)
@staticmethod
def _secret_key() -> bytes:
return dify_config.SECRET_KEY.encode()
@classmethod
def _archive_member_signature_payload(
cls,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
timestamp: str,
nonce: str,
) -> str:
return "|".join(
[
_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE,
tenant_id,
agent_id,
normalize_drive_key(key),
archive_file_kind.value,
archive_file_id,
normalize_drive_key(member_path),
timestamp,
nonce,
]
)
@classmethod
def _sign_archive_member_payload(cls, payload: str) -> str:
digest = hmac.new(cls._secret_key(), payload.encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode()
@classmethod
def sign_archive_member_url(
cls,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
for_external: bool,
as_attachment: bool = False,
) -> str:
base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
payload = cls._archive_member_signature_payload(
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
archive_file_kind=archive_file_kind,
archive_file_id=archive_file_id,
member_path=member_path,
timestamp=timestamp,
nonce=nonce,
)
query = urllib.parse.urlencode(
{
"tenant_id": tenant_id,
"agent_id": agent_id,
"key": normalize_drive_key(key),
"archive_file_kind": archive_file_kind.value,
"archive_file_id": archive_file_id,
"member_path": normalize_drive_key(member_path),
"timestamp": timestamp,
"nonce": nonce,
"sign": cls._sign_archive_member_payload(payload),
"as_attachment": str(as_attachment).lower(),
}
)
return f"{base_url}/files/agent-drive/archive-member?{query}"
@classmethod
def verify_archive_member_signature(
cls,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
timestamp: str,
nonce: str,
sign: str,
) -> bool:
payload = cls._archive_member_signature_payload(
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
archive_file_kind=archive_file_kind,
archive_file_id=archive_file_id,
member_path=member_path,
timestamp=timestamp,
nonce=nonce,
)
if sign != cls._sign_archive_member_payload(payload):
return False
current_time = int(time.time())
return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
def load_archive_member_for_signed_request(
self,
*,
tenant_id: str,
agent_id: str,
key: str,
archive_file_kind: AgentDriveFileKind,
archive_file_id: str,
member_path: str,
) -> tuple[bytes, str, str]:
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
payload = self._load_archive_member_bytes(
tenant_id=tenant_id,
archive_file_kind=archive_file_kind,
archive_file_id=archive_file_id,
member_path=member_path,
)
mime_type = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
filename = normalize_drive_key(key).rsplit("/", 1)[-1]
return payload, mime_type, filename
__all__ = [
"AgentDriveError",

View File

@ -96,6 +96,17 @@ class AppService:
filters.append(App.mode == AppMode.AGENT_CHAT)
elif params.mode == "agent":
filters.append(App.mode == AppMode.AGENT)
filters.append(
sa.exists()
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == App.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
.correlate(App)
)
elif params.mode == "all":
filters.append(App.mode != AppMode.AGENT)

View File

@ -31,6 +31,10 @@ class ComposerSoulLockPayload(BaseModel):
unlocked_from_version_id: str | None = None
class WorkflowAgentComposerQuery(BaseModel):
snapshot_id: str | None = Field(default=None, max_length=255)
class ComposerSavePayload(BaseModel):
variant: ComposerVariant
binding: ComposerBindingPayload | None = None

View File

@ -173,14 +173,6 @@ class InnerKnowledgeRetrieveRequest(BaseModel):
class InnerKnowledgeRetrieveUsage(ResponseModel):
"""Serialized LLM usage payload returned by dataset retrieval."""
model_config = ConfigDict(
from_attributes=True,
extra="forbid",
populate_by_name=True,
serialize_by_alias=True,
protected_namespaces=(),
)
prompt_tokens: int
completion_tokens: int
total_tokens: int

View File

@ -162,8 +162,15 @@ def test_request_builder_adds_knowledge_layer_when_configured():
run_input = _run_input()
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
{
"dataset_ids": ["dataset-1"],
"retrieval": {"mode": "multiple", "top_k": 4},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
],
}
)
@ -174,7 +181,7 @@ def test_request_builder_adds_knowledge_layer_when_configured():
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config)
assert knowledge_config.dataset_ids == ["dataset-1"]
assert knowledge_config.sets[0].dataset_ids == ["dataset-1"]
def test_request_builder_can_delete_on_exit_for_cleanup_paths():
@ -332,7 +339,7 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell():
assert shell_config.env[0].name == "PROJECT_NAME"
def test_workflow_request_builder_binds_shell_to_drive_when_configured():
def test_workflow_request_builder_binds_drive_to_shell_when_configured():
run_input = _run_input()
run_input.include_shell = True
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
@ -341,11 +348,11 @@ def test_workflow_request_builder_binds_shell_to_drive_when_configured():
layers = {layer.name: layer for layer in request.composition.layers}
layer_names = [layer.name for layer in request.composition.layers]
assert layers[DIFY_SHELL_LAYER_ID].deps == {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"drive": DIFY_DRIVE_LAYER_ID,
}
assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID)
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
assert shell_config.agent_stub_drive_ref == "agent-agent-1"
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
def test_agent_app_request_builder_omits_shell_layer_by_default():
@ -367,7 +374,7 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
assert shell_config.env[0].name == "APP_ENV"
def test_agent_app_request_builder_binds_shell_to_drive_when_configured():
def test_agent_app_request_builder_binds_drive_to_shell_when_configured():
run_input = _agent_app_input(include_shell=True)
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
@ -375,19 +382,26 @@ def test_agent_app_request_builder_binds_shell_to_drive_when_configured():
layers = {layer.name: layer for layer in request.composition.layers}
layer_names = [layer.name for layer in request.composition.layers]
assert layers[DIFY_SHELL_LAYER_ID].deps == {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"drive": DIFY_DRIVE_LAYER_ID,
}
assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID)
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
assert shell_config.agent_stub_drive_ref == "agent-agent-1"
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
run_input = _agent_app_input()
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
{
"dataset_ids": ["dataset-1", "dataset-2"],
"retrieval": {"mode": "multiple", "top_k": 2},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 2},
}
],
}
)
@ -398,7 +412,7 @@ def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config)
assert knowledge_config.dataset_ids == ["dataset-1", "dataset-2"]
assert knowledge_config.sets[0].dataset_ids == ["dataset-1", "dataset-2"]
# ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ─────

View File

@ -149,3 +149,55 @@ def test_generate_specs_is_idempotent(tmp_path):
assert [path.name for path in first_paths] == [path.name for path in second_paths]
for first_path, second_path in zip(first_paths, second_paths):
assert first_path.read_text(encoding="utf-8") == second_path.read_text(encoding="utf-8")
def test_generate_specs_include_agent_v2_knowledge_set_schema_and_query_enums(tmp_path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
console_path = next(path for path in written_paths if path.name == "console-openapi.json")
payload = json.loads(console_path.read_text(encoding="utf-8"))
schemas = payload["components"]["schemas"]
assert "AgentKnowledgeSetConfig" in schemas
assert schemas["AgentSoulKnowledgeConfig"]["properties"]["sets"]["items"]["$ref"] == (
"#/components/schemas/AgentKnowledgeSetConfig"
)
assert schemas["AgentKnowledgeQueryMode"]["enum"] == ["generated_query", "user_query"]
def test_checked_in_agent_v2_knowledge_openapi_and_generated_contracts_are_in_sync():
api_dir = Path(__file__).resolve().parents[3]
repo_root = api_dir.parent
markdown = (api_dir / "openapi" / "markdown" / "console-openapi.md").read_text(encoding="utf-8")
agent_types = (
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "types.gen.ts"
).read_text(encoding="utf-8")
apps_types = (
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "types.gen.ts"
).read_text(encoding="utf-8")
agent_zod = (
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "zod.gen.ts"
).read_text(encoding="utf-8")
apps_zod = (
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "zod.gen.ts"
).read_text(encoding="utf-8")
assert "#### AgentKnowledgeSetConfig" in markdown
assert "#### AgentSoulKnowledgeConfig" in markdown
assert "#### AgentKnowledgeQueryMode" in markdown
for content in (agent_types, apps_types):
assert "export type AgentKnowledgeSetConfig = {" in content
assert "export type AgentSoulKnowledgeConfig = {" in content
assert "AgentKnowledgeQueryMode" in content
assert "generated_query" in content
assert "user_query" in content
for content in (agent_zod, apps_zod):
assert "export const zAgentKnowledgeSetConfig = z.object({" in content
assert "export const zAgentSoulKnowledgeConfig = z.object({" in content
assert "zAgentKnowledgeQueryMode = z.enum([" in content
assert "generated_query" in content
assert "user_query" in content

View File

@ -28,11 +28,15 @@ from controllers.console.agent.roster import (
AgentAppApi,
AgentAppCopyApi,
AgentAppListApi,
AgentBuildDraftApi,
AgentBuildDraftApplyApi,
AgentBuildDraftCheckoutApi,
AgentDebugConversationRefreshApi,
AgentInviteOptionsApi,
AgentLogMessagesApi,
AgentLogsApi,
AgentLogSourcesApi,
AgentPublishApi,
AgentRosterVersionDetailApi,
AgentRosterVersionRestoreApi,
AgentRosterVersionsApi,
@ -95,7 +99,7 @@ def _agent_app_composer_response() -> dict:
},
"active_config_snapshot": _version_response(),
"agent_soul": {},
"save_options": ["save_to_current_version", "save_as_new_version"],
"save_options": ["save_to_current_version"],
}
@ -151,6 +155,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/agent/<uuid:agent_id>/composer/candidates",
"/agent/<uuid:agent_id>/features",
"/agent/<uuid:agent_id>/copy",
"/agent/<uuid:agent_id>/publish",
"/agent/<uuid:agent_id>/build-draft/checkout",
"/agent/<uuid:agent_id>/build-draft",
"/agent/<uuid:agent_id>/build-draft/apply",
"/agent/<uuid:agent_id>/referencing-workflows",
"/agent/<uuid:agent_id>/drive/files",
"/agent/<uuid:agent_id>/sandbox/files",
@ -520,6 +528,129 @@ def test_agent_debug_conversation_refresh_uses_current_user(
}
def test_agent_publish_and_build_draft_routes_call_composer_service(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
current_user = SimpleNamespace(id=account_id)
captured: dict[str, object] = {}
def publish_agent_app_draft(**kwargs: object) -> dict[str, object]:
captured["publish"] = kwargs
return {"result": "success", "active_config_snapshot_id": "version-1"}
def checkout_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
captured["checkout"] = kwargs
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
def load_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
captured["load"] = kwargs
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
def save_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
captured["save"] = kwargs
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
def apply_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
captured["apply"] = kwargs
return {"result": "success", "draft": {"id": "draft-1"}}
def discard_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
captured["discard"] = kwargs
return {"result": "success"}
monkeypatch.setattr(
roster_controller.AgentComposerService,
"publish_agent_app_draft",
publish_agent_app_draft,
)
monkeypatch.setattr(
roster_controller.AgentComposerService,
"checkout_agent_app_build_draft",
checkout_agent_app_build_draft,
)
monkeypatch.setattr(
roster_controller.AgentComposerService,
"load_agent_app_build_draft",
load_agent_app_build_draft,
)
monkeypatch.setattr(
roster_controller.AgentComposerService,
"save_agent_app_build_draft",
save_agent_app_build_draft,
)
monkeypatch.setattr(
roster_controller.AgentComposerService,
"apply_agent_app_build_draft",
apply_agent_app_build_draft,
)
monkeypatch.setattr(
roster_controller.AgentComposerService,
"discard_agent_app_build_draft",
discard_agent_app_build_draft,
)
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/publish",
json={"version_note": "publish v1"},
):
published = unwrap(AgentPublishApi.post)(AgentPublishApi(), "tenant-1", current_user, agent_id)
assert published["active_config_snapshot_id"] == "version-1"
assert captured["publish"] == {
"tenant_id": "tenant-1",
"agent_id": agent_id,
"account_id": account_id,
"version_note": "publish v1",
}
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/checkout",
json={"force": True},
):
checked_out = unwrap(AgentBuildDraftCheckoutApi.post)(
AgentBuildDraftCheckoutApi(), "tenant-1", current_user, agent_id
)
assert checked_out["draft"]["id"] == "build-draft-1"
assert captured["checkout"] == {
"tenant_id": "tenant-1",
"agent_id": agent_id,
"account_id": account_id,
"force": True,
}
with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft"):
loaded = unwrap(AgentBuildDraftApi.get)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
assert loaded["draft"]["id"] == "build-draft-1"
assert captured["load"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft",
json={"variant": "agent_app", "save_strategy": "save_to_current_version", "agent_soul": {}},
):
saved = unwrap(AgentBuildDraftApi.put)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
assert saved["draft"]["id"] == "build-draft-1"
assert captured["save"]["tenant_id"] == "tenant-1"
assert captured["save"]["agent_id"] == agent_id
assert captured["save"]["account_id"] == account_id
assert captured["save"]["payload"].variant == ComposerVariant.AGENT_APP
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/apply",
method="POST",
):
applied = unwrap(AgentBuildDraftApplyApi.post)(AgentBuildDraftApplyApi(), "tenant-1", current_user, agent_id)
assert applied == {"result": "success", "draft": {"id": "draft-1"}}
assert captured["apply"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft",
method="DELETE",
):
discarded = unwrap(AgentBuildDraftApi.delete)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
assert discarded == {"result": "success"}
assert captured["discard"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -963,10 +1094,11 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value,
"binding": {"binding_type": "roster_agent", "current_snapshot_id": "version-1"},
}
captured_load: dict[str, object] = {}
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_workflow_composer",
lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]),
lambda **kwargs: captured_load.update(kwargs) or _workflow_composer_response(node_id=kwargs["node_id"]),
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
@ -993,8 +1125,12 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
},
)
workflow_state = unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1")
with app.test_request_context("?snapshot_id=preview-version"):
workflow_state = unwrap(WorkflowAgentComposerApi.get)(
WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1"
)
assert workflow_state["node_id"] == "node-1"
assert captured_load["snapshot_id"] == "preview-version"
with app.test_request_context(json=payload):
saved_state = unwrap(WorkflowAgentComposerApi.put)(
WorkflowAgentComposerApi(), "tenant-1", account_id, app_model, "node-1"
@ -1092,13 +1228,11 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
"agent_soul": {"prompt": {"system_prompt": "x"}},
}
monkeypatch.setattr(composer_controller, "resolve_agent_app_model", lambda **kwargs: SimpleNamespace(id="app-1"))
def load_agent_app_composer(**kwargs: object) -> dict:
def load_agent_composer(**kwargs: object) -> dict:
captured["load"] = kwargs
return _agent_app_composer_response()
def save_agent_app_composer(**kwargs: object) -> dict:
def save_agent_composer(**kwargs: object) -> dict:
captured["save"] = kwargs
return _agent_app_composer_response()
@ -1112,13 +1246,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_agent_app_composer",
load_agent_app_composer,
"load_agent_composer",
load_agent_composer,
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_agent_app_composer",
save_agent_app_composer,
"save_agent_composer",
save_agent_composer,
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
monkeypatch.setattr(
@ -1133,13 +1267,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
)
assert unwrap(AgentComposerApi.get)(AgentComposerApi(), "tenant-1", agent_id)["variant"] == "agent_app"
assert cast(dict[str, object], captured["load"])["app_id"] == "app-1"
assert cast(dict[str, object], captured["load"])["agent_id"] == agent_id
with app.test_request_context(json=payload):
assert (
unwrap(AgentComposerApi.put)(AgentComposerApi(), "tenant-1", account_id, agent_id)["variant"] == "agent_app"
)
assert cast(dict[str, object], captured["save"])["app_id"] == "app-1"
assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id
assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), "tenant-1", agent_id) == {
"result": "success",
"errors": [],
@ -1150,7 +1284,7 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
candidates = unwrap(AgentComposerCandidatesApi.get)(AgentComposerCandidatesApi(), "tenant-1", account_id, agent_id)
assert candidates["variant"] == "agent_app"
assert cast(dict[str, object], captured["candidates"])["app_id"] == "app-1"
assert cast(dict[str, object], captured["candidates"])["agent_id"] == agent_id
def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
@ -1172,7 +1306,7 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
captured["stop"] = kwargs
return {"result": "success"}, 200
monkeypatch.setattr(completion_controller, "resolve_agent_app_model", resolve_agent_app_model)
monkeypatch.setattr(completion_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model)
monkeypatch.setattr(completion_controller, "_create_chat_message", create_chat_message)
monkeypatch.setattr(completion_controller, "_stop_chat_message", stop_chat_message)
@ -1375,7 +1509,7 @@ def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeyp
captured["detail"] = kwargs
return {"id": message_id}
monkeypatch.setattr(message_controller, "resolve_agent_app_model", resolve_agent_app_model)
monkeypatch.setattr(message_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model)
monkeypatch.setattr(message_controller, "_list_chat_messages", list_chat_messages)
monkeypatch.setattr(message_controller, "_update_message_feedback", update_message_feedback)
monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions)

View File

@ -119,7 +119,7 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
service = _AgentAppService()
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model())
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", lambda *, tenant_id, agent_id: _app_model())
monkeypatch.setattr(
module,
"query_params_from_request",
@ -151,7 +151,7 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytes
raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404)
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model())
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", lambda *, tenant_id, agent_id: _app_model())
monkeypatch.setattr(
module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".")
)

View File

@ -64,7 +64,7 @@ def test_list_by_agent_filters_value_pointers_out_of_console_payload():
raw = _raw(AgentDriveListByAgentApi.get)
with app.test_request_context("/?prefix=pdf-toolkit/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.manifest.return_value = [
@ -105,7 +105,7 @@ def test_skill_list_by_agent_calls_service():
raw = _raw(AgentDriveSkillListByAgentApi.get)
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.list_skills.return_value = [
@ -177,7 +177,7 @@ def test_skill_inspect_by_agent_returns_strict_json_response():
}
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP),
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.inspect_skill.return_value = payload
@ -256,7 +256,7 @@ def test_preview_by_agent_passes_through_and_maps_errors():
raw = _raw(AgentDrivePreviewByAgentApi.get)
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.preview.return_value = {
@ -272,7 +272,7 @@ def test_preview_by_agent_passes_through_and_maps_errors():
with app.test_request_context("/?key=ghost/SKILL.md"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP),
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.preview.side_effect = AgentDriveError(
@ -296,7 +296,7 @@ def test_download_by_agent_returns_signed_url_json():
raw = _raw(AgentDriveDownloadByAgentApi.get)
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.download_url.return_value = "https://signed.example/zip"

View File

@ -67,7 +67,7 @@ def test_upload_by_agent_resolves_app_and_standardizes_into_drive():
with _file_ctx(files={"file": b"zip-bytes"}):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.SkillStandardizeService") as svc,
):
svc.return_value.standardize.return_value = {"skill": {"path": "skill-a"}, "manifest": {}}
@ -174,7 +174,7 @@ def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id():
upload = SimpleNamespace(id="uf-1", name="sample.pdf")
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.console_ns") as ns,
patch(f"{_MOD}.db") as db_mock,
patch(f"{_MOD}.AgentDriveService") as drive,
@ -252,7 +252,7 @@ def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id():
raw = _raw(AgentDriveFilesByAgentApi.delete)
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
@ -316,7 +316,7 @@ def test_skill_delete_by_agent_uses_agent_route():
raw = _raw(AgentSkillByAgentApi.delete)
with _json_ctx(method="DELETE", query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}]
@ -360,7 +360,7 @@ def test_infer_tools_by_agent_uses_agent_route():
raw = _raw(AgentSkillInferToolsByAgentApi.post)
with _json_ctx(query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.SkillToolInferenceService") as svc,
):
svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None}

View File

@ -69,7 +69,7 @@ class TestGenerateSuccess:
def test_runtime_session_snapshot_id_is_stable_for_debugger_only(self):
assert (
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1")
is None
== "snap-1"
)
assert (
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.WEB_APP, snapshot_id="snap-1")
@ -111,7 +111,12 @@ class TestGenerateSuccess:
assert result == {"result": "ok"}
thread_obj.start.assert_called_once()
generator._resolve_agent.assert_called_once_with(app_model)
generator._resolve_agent.assert_called_once_with(
app_model,
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=user,
)
def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")

View File

@ -46,6 +46,13 @@ class _FakeCredentialsProvider:
return {"openai_api_key": "sk-test"}
@pytest.fixture(autouse=True)
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
class _NoToolsBuilder:
def build(self, **kwargs):
del kwargs

View File

@ -14,6 +14,7 @@ import pytest
from core.app.apps.agent_app import app_generator as gen_mod
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError
from core.app.entities.app_invoke_entities import InvokeFrom
_SOUL_DICT = {
"model": {
@ -84,14 +85,24 @@ class TestResolveAgent:
_patch_session(monkeypatch, [bound_agent, inner_agent, snapshot])
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
agent, snap, soul = AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type]
agent, snap, soul = AgentAppGenerator()._resolve_agent(
app_model,
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=SimpleNamespace(id="user-1"),
) # type: ignore[arg-type]
assert agent is inner_agent
assert snap is snapshot
assert agent is bound_agent
assert snap == snapshot.id
assert soul.model is not None
def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch):
_patch_session(monkeypatch, [None])
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
with pytest.raises(AgentAppGeneratorError, match="has no bound Agent"):
AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type]
AgentAppGenerator()._resolve_agent(
app_model,
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=SimpleNamespace(id="user-1"),
) # type: ignore[arg-type]

View File

@ -85,6 +85,49 @@ class _NoToolsBuilder:
del kwargs
def _mock_empty_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 123,
"mime_type": "text/markdown",
"hash": "hash-1",
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False},
{"key": "files/sample.pdf", "is_skill": False},
],
)
@pytest.fixture(autouse=True)
def _mock_default_agent_app_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
_mock_empty_drive_catalog(monkeypatch)
def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuildContext:
dify_context = SimpleNamespace(
tenant_id="tenant-1",
@ -128,7 +171,15 @@ class TestAgentAppRuntimeRequestBuilder:
req = result.request
assert req.purpose == "agent_app"
names = [layer.name for layer in req.composition.layers]
assert names == ["agent_soul_prompt", "agent_app_user_prompt", "execution_context", "history", "llm"]
assert names == [
"agent_soul_prompt",
"agent_app_user_prompt",
"execution_context",
"shell",
"drive",
"history",
"llm",
]
# plugin_id / provider normalized for plugin-daemon transport.
llm = next(layer for layer in req.composition.layers if layer.name == "llm")
assert llm.config.plugin_id == "langgenius/openai"
@ -169,12 +220,19 @@ class TestAgentAppRuntimeRequestBuilder:
"model": "gpt-4o-mini",
},
"knowledge": {
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
"query_config": {
"top_k": 3,
"score_threshold": 0.5,
"score_threshold_enabled": False,
},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {
"mode": "multiple",
"top_k": 3,
"score_threshold": None,
},
}
],
},
}
)
@ -189,10 +247,12 @@ class TestAgentAppRuntimeRequestBuilder:
assert knowledge.type == "dify.knowledge_base"
assert knowledge.deps == {"execution_context": "execution_context"}
dumped_config = knowledge.config.model_dump(mode="json", by_alias=True)
assert dumped_config["dataset_ids"] == ["dataset-1", "dataset-2"]
assert dumped_config["retrieval"]["mode"] == "multiple"
assert dumped_config["retrieval"]["top_k"] == 3
assert dumped_config["retrieval"]["score_threshold"] == 0.0
knowledge_set = dumped_config["sets"][0]
assert [dataset["id"] for dataset in knowledge_set["datasets"]] == ["dataset-1", "dataset-2"]
assert knowledge_set["query"] == {"mode": "generated_query", "value": None}
assert knowledge_set["retrieval"]["mode"] == "multiple"
assert knowledge_set["retrieval"]["top_k"] == 3
assert knowledge_set["retrieval"]["score_threshold"] == 0.0
def test_build_raises_when_model_missing(self):
builder = AgentAppRuntimeRequestBuilder(
@ -242,9 +302,26 @@ class TestAgentAppRuntimeRequestBuilder:
def _soul_with_model_and_skill() -> AgentSoulConfig:
soul = _soul_with_model()
soul.prompt.system_prompt = "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"
return soul
return AgentSoulConfig.model_validate(
{
"model": {
"plugin_id": "langgenius/openai",
"model_provider": "langgenius/openai/openai",
"model": "gpt-4o-mini",
},
"prompt": {"system_prompt": "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"},
"files": {
"skills": [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
}
]
},
}
)
class TestAgentAppDriveLayer:
@ -252,28 +329,7 @@ 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}
],
)
_mock_drive_catalog(monkeypatch)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@ -283,27 +339,20 @@ 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.deps == {"shell": DIFY_SHELL_LAYER_ID}
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
# shell enters first; drive uses that shell to materialize mentioned targets.
names = [layer.name for layer in result.request.composition.layers]
assert names.index("drive") == names.index("execution_context") + 1
assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 1
assert names.index("drive") == names.index(DIFY_SHELL_LAYER_ID) + 1
def test_drive_layer_injected_with_empty_catalog_and_shell_depends_on_it(self, monkeypatch: pytest.MonkeyPatch):
def test_drive_layer_injected_with_empty_catalog_and_drive_depends_on_shell(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]
@ -314,12 +363,14 @@ class TestAgentAppDriveLayer:
layers = {layer.name: layer for layer in result.request.composition.layers}
assert layers["drive"].config.drive_ref == "agent-agent-1"
assert layers["drive"].config.skills == []
assert layers[DIFY_SHELL_LAYER_ID].deps == {
"execution_context": "execution_context",
"drive": "drive",
}
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": "execution_context"}
assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref == "agent-agent-1"
assert layers["drive"].deps == {"shell": DIFY_SHELL_LAYER_ID}
def test_no_drive_layer_when_flag_disabled(self):
def test_no_drive_layer_when_flag_disabled(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@ -334,29 +385,7 @@ 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},
{"key": "files/sample.pdf", "is_skill": False},
],
)
_mock_drive_catalog(monkeypatch)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
@ -379,14 +408,7 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
_mock_drive_catalog(monkeypatch)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
@ -407,29 +429,7 @@ 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},
{"key": "files/sample.pdf", "is_skill": False},
],
)
_mock_drive_catalog(monkeypatch)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]"
@ -452,14 +452,6 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "

View File

@ -2,6 +2,7 @@ from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.ask_human import AskHumanToolResult
from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData
@ -50,6 +51,13 @@ class FakeCredentialsProvider:
return {"api_key": "secret-key"}
@pytest.fixture(autouse=True)
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
def _restored_file(*, transfer_method: FileTransferMethod, reference: str) -> File:
return File(
type=FileType.DOCUMENT,

View File

@ -36,6 +36,13 @@ class FakeCredentialsProvider:
return {"api_key": "secret-key"}
@pytest.fixture(autouse=True)
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
class CapturingCredentialsProvider:
def __init__(self) -> None:
self.provider_name: str | None = None
@ -181,7 +188,8 @@ def test_builds_create_run_request_from_agent_soul_and_node_job():
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
assert dumped["composition"]["layers"][-1]["config"]["json_schema"]["properties"]["summary"]["type"] == "string"
assert DIFY_AGENT_HISTORY_LAYER_ID in layers
assert result.redacted_request["composition"]["layers"][5]["config"]["credentials"] == "[REDACTED]"
redacted_layers = {layer["name"]: layer for layer in result.redacted_request["composition"]["layers"]}
assert redacted_layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["credentials"] == "[REDACTED]"
def test_normalizes_langgenius_model_provider_for_agent_backend_transport():
@ -262,7 +270,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
assert report_schema["oneOf"][3]["required"] == ["transfer_method", "url"]
assert output_schema["properties"]["confidence"]["type"] == "number"
assert output_schema["required"] == ["report"]
assert dumped["composition"]["layers"][5]["config"]["model_settings"] == {"temperature": 0.2}
assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_settings"] == {"temperature": 0.2}
assert result.metadata["runtime_support"]["reserved_status"]["tools.dify_tools"] == "supported_when_config_valid"
assert result.metadata["runtime_support"]["reserved_status"]["tools.cli_tools"] == "supported_by_shell_bootstrap"
assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == []
@ -512,12 +520,55 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
"model": "gpt-test",
},
"knowledge": {
"datasets": [{"id": "dataset-1"}, {"id": " "}, {"id": "dataset-2"}],
"query_config": {
"top_k": 6,
"score_threshold": 0.4,
"score_threshold_enabled": True,
},
"sets": [
{
"id": "support",
"name": "Support KB",
"description": "Support content",
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {
"mode": "multiple",
"top_k": 6,
"score_threshold": 0.4,
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
},
"metadata_filtering": {
"mode": "manual",
"conditions": {
"logical_operator": "and",
"conditions": [
{"name": "category", "comparison_operator": "contains", "value": "auth"}
],
},
},
},
{
"id": "release",
"name": "Release Notes",
"datasets": [{"id": "dataset-3"}],
"query": {"mode": "user_query", "value": "release notes"},
"retrieval": {
"mode": "single",
"model": {
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {"temperature": 0.2},
},
},
"metadata_filtering": {
"mode": "automatic",
"model_config": {
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {},
},
},
},
],
},
}
),
@ -531,25 +582,73 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
knowledge_layer = layers["knowledge"]
assert knowledge_layer["type"] == "dify.knowledge_base"
assert knowledge_layer["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
assert knowledge_layer["config"] == {
"dataset_ids": ["dataset-1", "dataset-2"],
"retrieval": {
"mode": "multiple",
"top_k": 6,
"score_threshold": 0.4,
"reranking_mode": "reranking_model",
"reranking_enable": True,
"reranking_model": None,
"weights": None,
"model": None,
assert knowledge_layer["config"]["sets"] == [
{
"id": "support",
"name": "Support KB",
"description": "Support content",
"datasets": [
{"id": "dataset-1", "name": None, "description": None},
{"id": "dataset-2", "name": None, "description": None},
],
"query": {"mode": "generated_query", "value": None},
"retrieval": {
"mode": "multiple",
"top_k": 6,
"score_threshold": 0.4,
"reranking_mode": "reranking_model",
"reranking_enable": True,
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
"model": None,
},
"metadata_filtering": {
"mode": "manual",
"metadata_model_config": None,
"conditions": {
"logical_operator": "and",
"conditions": [{"name": "category", "comparison_operator": "contains", "value": "auth"}],
},
},
},
"metadata_filtering": {"mode": "disabled", "metadata_model_config": None, "conditions": None},
"max_result_content_chars": 2000,
"max_observation_chars": 12000,
}
{
"id": "release",
"name": "Release Notes",
"description": None,
"datasets": [{"id": "dataset-3", "name": None, "description": None}],
"query": {"mode": "user_query", "value": "release notes"},
"retrieval": {
"mode": "single",
"top_k": None,
"score_threshold": 0.0,
"reranking_mode": "reranking_model",
"reranking_enable": True,
"reranking_model": None,
"weights": None,
"model": {
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {"temperature": 0.2},
},
},
"metadata_filtering": {
"mode": "automatic",
"metadata_model_config": {
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {},
},
"conditions": None,
},
},
]
assert knowledge_layer["config"]["max_result_content_chars"] == 2000
assert knowledge_layer["config"]["max_observation_chars"] == 12000
def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits_it():
def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero():
context = _context()
snapshot = AgentConfigSnapshot(
id="snapshot-1",
@ -565,8 +664,19 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits
"model": "gpt-test",
},
"knowledge": {
"datasets": [{"id": "dataset-1"}],
"query_config": {},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {
"mode": "multiple",
"top_k": 4,
"score_threshold": None,
},
}
],
},
}
),
@ -577,10 +687,10 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits
dumped = result.request.model_dump(mode="json")
knowledge_layer = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "knowledge")
assert knowledge_layer["config"]["retrieval"]["top_k"] == 4
assert knowledge_layer["config"]["sets"][0]["retrieval"]["score_threshold"] == 0.0
def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids():
def test_build_skips_knowledge_layer_when_agent_soul_has_no_sets():
context = _context()
snapshot = AgentConfigSnapshot(
id="snapshot-1",
@ -595,9 +705,7 @@ def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids():
"model_provider": "openai",
"model": "gpt-test",
},
"knowledge": {
"datasets": [{"id": " "}, {}],
},
"knowledge": {"sets": []},
}
),
)
@ -931,10 +1039,9 @@ def test_workflow_run_request_contains_drive_layer_with_empty_catalog(monkeypatc
"mentioned_skill_keys": [],
"mentioned_file_keys": [],
}
assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"drive": "drive",
}
assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] == "agent-agent-1"
assert layers["drive"]["deps"] == {"shell": DIFY_SHELL_LAYER_ID}
def test_build_drive_layer_config_requires_agent_identity():
@ -960,11 +1067,12 @@ def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch
dumped = result.request.model_dump(mode="json")
layer_names = [layer["name"] for layer in dumped["composition"]["layers"]]
assert "drive" in layer_names
# injected right after execution_context, before history/llm
assert layer_names.index("drive") == layer_names.index("execution_context") + 1
# shell enters first; drive uses that shell to materialize mentioned targets.
assert layer_names.index(DIFY_SHELL_LAYER_ID) == layer_names.index("execution_context") + 1
assert layer_names.index("drive") == layer_names.index(DIFY_SHELL_LAYER_ID) + 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["deps"] == {"shell": DIFY_SHELL_LAYER_ID}
assert drive["config"]["drive_ref"] == "agent-agent-1"
assert drive["config"]["skills"] == [
{
@ -1031,7 +1139,10 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
assert "" not in soul_prompt.config.prefix
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled():
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()
@ -1094,7 +1205,15 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu
soul = AgentSoulConfig.model_validate(
{
"knowledge": {
"datasets": [{"id": "dataset-1", "name": "Product Docs"}],
"sets": [
{
"id": "product",
"name": "Product Docs",
"datasets": [{"id": "dataset-1", "name": "Product Docs"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
],
}
}
)
@ -1106,13 +1225,13 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu
assert all("knowledge" not in w["section"] for w in manifest["unsupported_runtime_warnings"])
def test_feature_manifest_treats_blank_knowledge_dataset_ids_as_not_configured():
def test_feature_manifest_treats_empty_knowledge_sets_as_not_configured():
from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest
soul = AgentSoulConfig.model_validate(
{
"knowledge": {
"datasets": [{"id": " "}, {}],
"sets": [],
}
}
)

View File

@ -55,6 +55,33 @@ def _snapshot() -> AgentConfigSnapshot:
)
def _snapshot_with_knowledge_dataset(dataset_id: str) -> AgentConfigSnapshot:
return AgentConfigSnapshot(
id="snapshot-1",
tenant_id="tenant-1",
agent_id="agent-1",
version=1,
config_snapshot=AgentSoulConfig(
model=AgentSoulModelConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="gpt-test",
),
knowledge={
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": dataset_id}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
},
),
)
def _graph(edges: list[dict]) -> dict:
return {
"nodes": [
@ -515,6 +542,35 @@ def test_publish_validation_rejects_missing_file_ref():
)
def test_publish_validation_rejects_missing_or_out_of_scope_knowledge_datasets(
monkeypatch: pytest.MonkeyPatch,
):
dataset_id = "550e8400-e29b-41d4-a716-446655440000"
node_job = WorkflowNodeJobConfig.model_validate({})
snapshot = _snapshot_with_knowledge_dataset(dataset_id)
session = Mock()
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
captured = {}
def fake_get_datasets_by_ids(ids, tenant_id):
captured["ids"] = ids
captured["tenant_id"] = tenant_id
return [], 0
import services.dataset_service as dataset_service_module
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
with pytest.raises(WorkflowAgentNodeValidationError, match=dataset_id):
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
)
assert captured == {"ids": [dataset_id], "tenant_id": "tenant-1"}
def test_publish_validation_accepts_tool_node_agentic_manual_mode():
session = Mock()

View File

@ -64,7 +64,7 @@ def _run_migration_step(module: object, engine: sa.Engine, step_name: str) -> No
module.op = original_op
def test_upgrade_adds_skill_columns_and_index_and_strips_snapshot_data() -> None:
def test_upgrade_adds_skill_columns_and_index_and_preserves_snapshot_data() -> None:
engine = sa.create_engine("sqlite:///:memory:")
_create_pre_upgrade_schema(engine)
snapshot = {
@ -91,7 +91,7 @@ def test_upgrade_adds_skill_columns_and_index_and_strips_snapshot_data() -> None
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)
assert json.loads(stored_snapshot) == snapshot
def test_downgrade_drops_skill_columns_and_index_without_reconstructing_legacy_data() -> None:

View File

@ -7,6 +7,8 @@ from sqlalchemy.exc import IntegrityError
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@ -34,6 +36,9 @@ def test_agent_enums_match_prd_boundaries():
assert AgentStatus.ARCHIVED.value == "archived"
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version"
assert AgentConfigRevisionOperation.RESTORE_VERSION.value == "restore_version"
assert AgentConfigRevisionOperation.PUBLISH_DRAFT.value == "publish_draft"
assert AgentConfigDraftType.DRAFT.value == "draft"
assert AgentConfigDraftType.DEBUG_BUILD.value == "debug_build"
assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent"
assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent"
@ -136,6 +141,23 @@ def test_current_snapshot_stores_agent_soul_snapshot_as_long_text_json():
assert version.config_snapshot_dict["env"]["secret_refs"][0]["provider_credential_id"] == "cred-1"
def test_agent_config_draft_stores_editable_agent_soul_as_long_text_json():
config_snapshot = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "draft prompt"}})
draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
draft_owner_key="",
config_snapshot=config_snapshot,
)
config_snapshot_column = AgentConfigDraft.__table__.c.config_snapshot
assert isinstance(config_snapshot_column.type, JSONModelColumn)
assert config_snapshot_column.server_default is None
assert draft.config_snapshot_dict == config_snapshot.model_dump(mode="json")
assert draft.config_snapshot_dict["prompt"]["system_prompt"] == "draft prompt"
def test_workflow_binding_stores_node_job_config_separately_from_agent_soul():
node_job_config = {
"schema_version": 1,
@ -166,6 +188,7 @@ def test_long_text_columns_do_not_use_mysql_incompatible_server_defaults():
assert isinstance(column.type, LongText)
assert column.server_default is None
assert AgentConfigSnapshot.__table__.c.config_snapshot.server_default is None
assert AgentConfigDraft.__table__.c.config_snapshot.server_default is None
assert WorkflowAgentNodeBinding.__table__.c.node_job_config.server_default is None

View File

@ -1,4 +1,5 @@
import pytest
from pydantic import ValidationError
from models.agent_config_entities import AgentKnowledgeQueryMode, AgentSoulModelConfig, DeclaredOutputType
from services.agent.composer_service import AgentComposerService
@ -26,6 +27,24 @@ def test_workflow_variant_rejects_agent_app_only_fields():
)
def test_workflow_variant_accepts_agent_soul_files_section():
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.WORKFLOW,
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY,
"agent_soul": {
"schema_version": 1,
"prompt": {"system_prompt": "jjjj"},
"files": {"skills": [], "files": []},
},
}
)
assert payload.agent_soul is not None
assert payload.agent_soul.files.skills == []
assert payload.agent_soul.files.files == []
def test_agent_app_variant_rejects_workflow_node_job():
with pytest.raises(ValueError):
ComposerSavePayload.model_validate(
@ -131,14 +150,144 @@ def test_knowledge_query_mode_uses_stable_backend_enums():
config = AgentSoulConfig.model_validate(
{
"knowledge": {
"datasets": [{"dataset_id": "dataset-1"}],
"query_mode": "generated_query",
"query_config": {"generation_prompt": "Create a retrieval query."},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
],
}
}
)
assert config.knowledge.query_mode == AgentKnowledgeQueryMode.GENERATED_QUERY
assert config.knowledge.sets[0].query.mode == AgentKnowledgeQueryMode.GENERATED_QUERY
@pytest.mark.parametrize(
("knowledge_payload", "match"),
[
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
{
"id": "support",
"name": "Billing KB",
"datasets": [{"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
]
},
"knowledge set ids must be unique",
),
(
{
"sets": [
{
"id": "support",
"name": "Shared KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
{
"id": "billing",
"name": "Shared KB",
"datasets": [{"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
]
},
"knowledge set names must be unique",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}, {"id": " dataset-1 "}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
]
},
"knowledge set dataset ids must be unique",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "user_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
]
},
"knowledge query.value is required for user_query mode",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "single"},
},
]
},
"knowledge retrieval.model is required for single mode",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
"metadata_filtering": {"mode": "automatic"},
},
]
},
"metadata_filtering.model_config is required for automatic mode",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
"metadata_filtering": {"mode": "manual"},
},
]
},
"metadata_filtering.conditions is required for manual mode",
),
],
)
def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str):
with pytest.raises(ValidationError, match=match):
AgentSoulConfig.model_validate({"knowledge": knowledge_payload})
def test_agent_soul_model_config_is_first_class_without_credentials():

View File

@ -8,6 +8,8 @@ from sqlalchemy.exc import IntegrityError
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentDebugConversation,
@ -26,8 +28,8 @@ from models.agent_config_entities import (
DeclaredOutputType,
WorkflowNodeJobConfig,
)
from models.enums import ConversationFromSource, ConversationStatus
from models.model import Conversation, IconType
from models.enums import AppStatus, ConversationFromSource, ConversationStatus
from models.model import App, AppMode, Conversation, IconType
from models.workflow import Workflow
from services.agent import composer_service, roster_service
from services.agent.agent_soul_state import agent_soul_has_model
@ -37,6 +39,7 @@ from services.agent.errors import (
AgentNameConflictError,
AgentNotFoundError,
AgentVersionConflictError,
AgentVersionNotFoundError,
InvalidComposerConfigError,
)
from services.agent.roster_service import AgentRosterService
@ -156,6 +159,96 @@ def test_load_workflow_composer_serializes_existing_binding(monkeypatch: pytest.
assert result == {"agent": "agent-1", "version": "version-1"}
def test_load_workflow_composer_uses_roster_preview_snapshot(monkeypatch: pytest.MonkeyPatch):
binding = SimpleNamespace(
agent_id="agent-1",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
current_snapshot_id="binding-version",
)
agent = SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER, active_config_snapshot_id="active-version")
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding)
monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: agent)
monkeypatch.setattr(
AgentComposerService,
"_require_version",
lambda **kwargs: SimpleNamespace(id=kwargs["version_id"]),
)
monkeypatch.setattr(
AgentComposerService,
"_serialize_workflow_state",
lambda **kwargs: {
"binding_snapshot_id": kwargs["binding"].current_snapshot_id,
"version": kwargs["version"].id,
},
)
result = AgentComposerService.load_workflow_composer(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
snapshot_id="preview-version",
)
assert result == {"binding_snapshot_id": "binding-version", "version": "preview-version"}
def test_load_workflow_composer_uses_inline_preview_snapshot(monkeypatch: pytest.MonkeyPatch):
binding = SimpleNamespace(
agent_id="inline-agent-1",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
current_snapshot_id="inline-version-1",
app_id="app-1",
workflow_id="workflow-1",
node_id="node-1",
)
agent = SimpleNamespace(
id="inline-agent-1",
scope=AgentScope.WORKFLOW_ONLY,
app_id="app-1",
workflow_id="workflow-1",
workflow_node_id="node-1",
active_config_snapshot_id="inline-version-1",
)
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding)
monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: agent)
monkeypatch.setattr(
AgentComposerService,
"_require_version",
lambda **kwargs: SimpleNamespace(id=kwargs["version_id"]),
)
monkeypatch.setattr(
AgentComposerService,
"_serialize_workflow_state",
lambda **kwargs: {"agent": kwargs["agent"].id, "version": kwargs["version"].id},
)
result = AgentComposerService.load_workflow_composer(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
snapshot_id="inline-preview-version",
)
assert result == {"agent": "inline-agent-1", "version": "inline-preview-version"}
def test_load_workflow_composer_rejects_preview_without_binding(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None)
with pytest.raises(AgentVersionNotFoundError):
AgentComposerService.load_workflow_composer(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
snapshot_id="preview-version",
)
@pytest.mark.parametrize(
("strategy", "helper_name"),
[
@ -271,16 +364,16 @@ def test_publish_save_strategies_run_publish_validation(strategy: ComposerSaveSt
def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest.MonkeyPatch):
fake_session = FakeSession(scalar=[None])
created_version = SimpleNamespace(id="version-1")
saved_draft = SimpleNamespace(id="draft-1", config_snapshot_dict={"prompt": {"system_prompt": "x"}})
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
monkeypatch.setattr(AgentComposerService, "_create_config_version", lambda **kwargs: created_version)
monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True})
monkeypatch.setattr(AgentComposerService, "_save_agent_draft", lambda **kwargs: saved_draft)
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True})
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP.value,
"save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
"new_agent_name": "Analyst",
"agent_soul": {"prompt": {"system_prompt": "x"}},
}
@ -293,25 +386,66 @@ def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest.
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"loaded": True}
assert fake_session.added[0].name == "Analyst"
assert fake_session.added[0].active_config_snapshot_id == "version-1"
assert fake_session.added[0].active_config_has_model is False
assert fake_session.added[0].active_config_snapshot_id is None
assert fake_session.commits == 1
def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.MonkeyPatch):
def test_load_agent_app_composer_exposes_draft_save_only(monkeypatch: pytest.MonkeyPatch):
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="version-1",
updated_by="account-1",
created_by="account-1",
app_id="app-1",
backing_app_id="app-1",
scope=AgentScope.ROSTER,
status=AgentStatus.ACTIVE,
)
draft = SimpleNamespace(config_snapshot_dict={"prompt": {"system_prompt": "x"}})
monkeypatch.setattr(AgentComposerService, "_require_agent_app_agent", lambda **kwargs: agent)
monkeypatch.setattr(AgentComposerService, "_get_or_create_agent_draft", lambda **kwargs: draft)
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: None)
monkeypatch.setattr(AgentComposerService, "_serialize_agent", lambda _agent: {"id": _agent.id})
monkeypatch.setattr(AgentComposerService, "_serialize_version", lambda _version: None)
monkeypatch.setattr(AgentComposerService, "_serialize_draft", lambda _draft: {"id": "draft-1"})
result = AgentComposerService.load_agent_app_composer(tenant_id="tenant-1", app_id="app-1")
assert result["save_options"] == [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value]
def test_save_agent_app_composer_rejects_version_save_strategy():
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP.value,
"save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value,
"agent_soul": {"prompt": {"system_prompt": "x"}},
}
)
with pytest.raises(InvalidComposerConfigError, match="Use the publish endpoint"):
AgentComposerService.save_agent_app_composer(
tenant_id="tenant-1",
app_id="app-1",
account_id="account-1",
payload=payload,
)
def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.MonkeyPatch):
agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1", updated_by=None)
fake_session = FakeSession(scalar=[agent])
updated = {}
saved = {}
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: SimpleNamespace(id="version-1"))
monkeypatch.setattr(
AgentComposerService,
"_update_current_version",
lambda **kwargs: updated.update(kwargs) or SimpleNamespace(id="version-2"),
"_save_agent_draft",
lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"),
)
monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True})
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True})
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP.value,
@ -326,12 +460,118 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.Mon
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"loaded": True}
assert updated["operation"].value == "save_current_version"
assert agent.active_config_has_model is True
assert saved["draft_type"] == AgentConfigDraftType.DRAFT
assert saved["agent_soul"].model_dump(mode="json") == _agent_soul_with_model().model_dump(mode="json")
assert fake_session._scalar == []
assert fake_session.commits == 1
def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch):
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
name="Iris",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="version-1",
)
draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
draft_owner_key="",
base_snapshot_id="version-1",
config_snapshot=_agent_soul_with_model(),
)
version = SimpleNamespace(id="version-2")
fake_session = FakeSession(scalar=[agent, draft])
created: dict[str, object] = {}
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **kwargs: None)
monkeypatch.setattr(
AgentComposerService,
"_create_config_version",
lambda **kwargs: created.update(kwargs) or version,
)
monkeypatch.setattr(AgentComposerService, "_serialize_version", lambda _version: {"id": _version.id})
result = AgentComposerService.publish_agent_app_draft(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="account-1",
version_note="ship it",
)
assert result["result"] == "success"
assert result["active_config_snapshot_id"] == "version-2"
assert result["draft"]["base_snapshot_id"] == "version-2"
assert created["operation"] == AgentConfigRevisionOperation.PUBLISH_DRAFT
assert created["previous_snapshot_id"] == "version-1"
assert agent.active_config_snapshot_id == "version-2"
assert agent.active_config_has_model is True
assert fake_session.commits == 1
def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkeypatch: pytest.MonkeyPatch):
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
name="Iris",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="version-1",
)
normal_draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft_owner_key="",
base_snapshot_id="version-1",
config_snapshot=_agent_soul_with_model(),
)
fake_session = FakeSession(scalar=[agent, normal_draft, None])
monkeypatch.setattr(composer_service.db, "session", fake_session)
checked_out = AgentComposerService.checkout_agent_app_build_draft(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="account-1",
)
build_draft = fake_session.added[0]
assert checked_out["draft"]["id"] == build_draft.id
assert checked_out["draft"]["draft_type"] == AgentConfigDraftType.DEBUG_BUILD.value
assert checked_out["draft"]["account_id"] == "account-1"
assert checked_out["draft"]["base_snapshot_id"] == "version-1"
assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict
assert fake_session.commits == 1
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft])
monkeypatch.setattr(composer_service.db, "session", fake_session)
applied = AgentComposerService.apply_agent_app_build_draft(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="account-1",
)
assert applied["result"] == "success"
assert applied["draft"]["id"] == normal_draft.id
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
assert fake_session.deleted == [build_draft]
assert fake_session.commits == 1
def test_agent_app_composer_candidates_and_impact(monkeypatch: pytest.MonkeyPatch):
bindings = [
SimpleNamespace(app_id="app-1", workflow_id="workflow-1", node_id="node-1"),
@ -345,14 +585,14 @@ def test_agent_app_composer_candidates_and_impact(monkeypatch: pytest.MonkeyPatc
raise ValueError("draft workflow not found")
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", _no_draft_workflow)
monkeypatch.setattr(AgentComposerService, "_load_agent_app_soul", lambda **kwargs: None)
monkeypatch.setattr(AgentComposerService, "_load_agent_soul", lambda **kwargs: None)
monkeypatch.setattr(AgentComposerService, "_workspace_dify_tools", lambda **kwargs: [])
workflow_candidates = AgentComposerService.get_workflow_candidates(
tenant_id="tenant-1", app_id="app-1", node_id="node-1", user_id="account-1"
)
agent_app_candidates = AgentComposerService.get_agent_app_candidates(
tenant_id="tenant-1", app_id="app-1", user_id="account-1"
tenant_id="tenant-1", agent_id="agent-1", user_id="account-1"
)
impact = AgentComposerService.calculate_impact(tenant_id="tenant-1", current_snapshot_id="version-1")
@ -376,13 +616,28 @@ def test_serialize_workflow_state_changes_lock_and_save_options(monkeypatch: pyt
node_id="node-1",
node_job_config='{"workflow_prompt":"do work"}',
)
agent = Agent(id="agent-1", name="Analyst", description="", scope=AgentScope.ROSTER, status=AgentStatus.ACTIVE)
agent = Agent(
id="agent-1",
name="Analyst",
description="Clarifies tenders",
role="Tender Analyst",
icon_type="emoji",
icon="robot",
icon_background="#F5F3FF",
scope=AgentScope.ROSTER,
source=AgentSource.ROSTER,
status=AgentStatus.ACTIVE,
)
version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}')
monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1})
state = AgentComposerService._serialize_workflow_state(binding=binding, agent=agent, version=version)
assert state["soul_lock"]["locked"] is True
assert state["agent"]["role"] == "Tender Analyst"
assert state["agent"]["icon_type"] == "emoji"
assert state["agent"]["icon"] == "robot"
assert state["agent"]["icon_background"] == "#F5F3FF"
assert "save_as_new_version" in state["save_options"]
assert state["agent_soul"]["app_features"] == {}
# Stage 4 §10.1 (D-3): binding with no declared_outputs → response surfaces
@ -404,7 +659,14 @@ def test_serialize_workflow_state_passes_user_declared_outputs_through_effective
'{"workflow_prompt":"work","declared_outputs":[{"name":"summary","type":"string","required":true}]}'
),
)
agent = Agent(id="agent-1", name="Analyst", description="", scope=AgentScope.ROSTER, status=AgentStatus.ACTIVE)
agent = Agent(
id="agent-1",
name="Analyst",
description="",
scope=AgentScope.ROSTER,
source=AgentSource.ROSTER,
status=AgentStatus.ACTIVE,
)
version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}')
monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1})
@ -1196,6 +1458,7 @@ def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytes
fake_session = FakeSession()
monkeypatch.setattr(composer_service.db, "session", fake_session)
created_apps = []
hidden_backing_apps = []
backing_agent = Agent(
id="roster-agent-1",
tenant_id="tenant-1",
@ -1215,6 +1478,10 @@ def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytes
def __init__(self, session):
self.session = session
def create_hidden_backing_app_for_workflow_agent(self, **kwargs):
hidden_backing_apps.append(kwargs)
return SimpleNamespace(id="hidden-app-1")
def get_app_backing_agent(self, *, tenant_id, app_id):
assert tenant_id == "tenant-1"
assert app_id == "app-agent-1"
@ -1253,6 +1520,8 @@ def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytes
assert workflow_agent.active_config_snapshot_id == "version-with-model"
assert workflow_agent.active_config_has_model is True
assert workflow_agent.backing_app_id == "hidden-app-1"
assert hidden_backing_apps[0]["name"] == "Workflow Agent node-1"
assert roster_agent.active_config_snapshot_id == "version-with-model"
assert roster_agent.active_config_has_model is True
assert roster_agent.source == AgentSource.AGENT_APP
@ -1481,7 +1750,9 @@ def test_roster_list_and_invite_options(monkeypatch: pytest.MonkeyPatch):
scalar=[2, 1, SimpleNamespace(id="workflow-1")],
scalars=[
[agent, unconfigured_agent],
[],
[agent],
[],
[SimpleNamespace(agent_id="agent-1", node_id="node-1")],
],
)
@ -1568,7 +1839,15 @@ def test_active_config_is_published_flags_handle_matching_and_empty_snapshots():
status=AgentStatus.ACTIVE,
active_config_snapshot_id=None,
)
service = AgentRosterService(FakeSession(scalars=[["agent-1"], ["agent-1"]]))
published_draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
draft_owner_key="",
base_snapshot_id="version-1",
config_snapshot=AgentSoulConfig(),
)
service = AgentRosterService(FakeSession(scalars=[["agent-1"], [published_draft], ["agent-1"], [published_draft]]))
flags = service.load_active_config_is_published_by_agent_id(tenant_id="tenant-1", agents=[agent, draft_agent])
@ -1580,6 +1859,62 @@ def test_active_config_is_published_flags_handle_matching_and_empty_snapshots():
) == {"agent-2": False}
def test_active_config_is_published_skips_empty_agent_ids():
empty_id_agent = Agent(
id="",
tenant_id="tenant-1",
name="Broken",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id=None,
)
fake_session = FakeSession(scalars=[["should-not-be-read"]])
assert (
AgentRosterService(fake_session).load_active_config_is_published_by_agent_id(
tenant_id="tenant-1",
agents=[empty_id_agent],
)
== {}
)
assert fake_session._scalars == [["should-not-be-read"]]
def test_load_app_backing_agents_skips_empty_agent_ids():
valid_agent = Agent(
id="agent-1",
tenant_id="tenant-1",
name="Valid",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
app_id="app-1",
status=AgentStatus.ACTIVE,
)
empty_id_agent = Agent(
id="",
tenant_id="tenant-1",
name="Broken",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
app_id="app-2",
status=AgentStatus.ACTIVE,
)
result = AgentRosterService(FakeSession(scalars=[[valid_agent, empty_id_agent]])).load_app_backing_agents_by_app_id(
tenant_id="tenant-1",
app_ids=["app-1", "app-2"],
)
assert result == {"app-1": valid_agent}
def test_published_references_include_app_display_fields_and_sort_by_updated_at():
recent_updated_at = datetime(2026, 1, 7, 3, 4, 5, tzinfo=UTC)
stale_updated_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC)
@ -1765,6 +2100,42 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch
assert loaded_versions["version-1"].agent_id == "agent-1"
def test_get_agent_runtime_app_model_creates_hidden_backing_app_for_existing_inline_agent():
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
app_id="workflow-app-1",
workflow_id="workflow-1",
workflow_node_id="node-1",
name="Inline Agent",
description="desc",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
status=AgentStatus.ACTIVE,
created_by="account-1",
updated_by="account-1",
)
backing_app = App(
id="generated-1",
tenant_id="tenant-1",
name="Inline Agent",
mode=AppMode.AGENT,
status=AppStatus.NORMAL,
)
session = FakeSession(scalar=[agent, backing_app])
service = AgentRosterService(session)
resolved_app = service.get_agent_runtime_app_model(tenant_id="tenant-1", agent_id="agent-1")
assert resolved_app is backing_app
assert agent.backing_app_id == "generated-1"
assert session.commits == 1
created_app = next(value for value in session.added if isinstance(value, App))
assert created_app.enable_site is False
assert created_app.enable_api is False
def test_agent_app_debug_conversation_create_reuse_and_recreate():
agent = Agent(
id="agent-1",
@ -1849,7 +2220,7 @@ def test_agent_app_debug_conversation_requires_app_binding():
)
def test_load_or_create_agent_app_debug_conversations_filters_agent_apps():
def test_load_or_create_agent_app_debug_conversations_supports_runtime_backed_agents():
valid_agent = Agent(
id="agent-1",
tenant_id="tenant-1",
@ -1888,10 +2259,11 @@ def test_load_or_create_agent_app_debug_conversations_filters_agent_apps():
account_id="account-1",
)
assert list(result) == ["agent-1"]
assert list(result) == ["agent-1", "agent-3"]
assert result["agent-1"]
assert result["agent-3"]
assert fake_session.commits == 1
assert len([value for value in fake_session.added if isinstance(value, AgentDebugConversation)]) == 1
assert len([value for value in fake_session.added if isinstance(value, AgentDebugConversation)]) == 2
def test_agent_app_visible_versions_exclude_draft_saves():
@ -1902,6 +2274,7 @@ def test_agent_app_visible_versions_exclude_draft_saves():
roster_operations = AgentRosterService._visible_version_operations(roster_agent)
assert agent_app_operations == {
AgentConfigRevisionOperation.PUBLISH_DRAFT,
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
AgentConfigRevisionOperation.RESTORE_VERSION,
@ -1913,7 +2286,7 @@ def test_agent_app_visible_versions_exclude_draft_saves():
def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pytest.MonkeyPatch):
fake_session = FakeSession(scalar=["version-2", 6])
fake_session = FakeSession(scalar=["version-2", None])
service = AgentRosterService(fake_session)
agent = Agent(
id="agent-1",
@ -1944,19 +2317,22 @@ def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pyte
account_id="account-1",
)
assert restored == {"result": "success", "active_config_snapshot_id": "version-2"}
assert agent.active_config_snapshot_id == "version-2"
assert agent.active_config_has_model is True
assert restored == {
"result": "success",
"active_config_snapshot_id": "version-4",
"draft_config_id": fake_session.added[0].id,
"restored_version_id": "version-2",
}
assert agent.active_config_snapshot_id == "version-4"
assert agent.updated_by == "account-1"
assert fake_session.commits == 1
revision = fake_session.added[0]
assert revision.tenant_id == "tenant-1"
assert revision.agent_id == "agent-1"
assert revision.previous_snapshot_id == "version-4"
assert revision.current_snapshot_id == "version-2"
assert revision.revision == 7
assert revision.operation == AgentConfigRevisionOperation.RESTORE_VERSION
assert revision.created_by == "account-1"
draft = fake_session.added[0]
assert draft.tenant_id == "tenant-1"
assert draft.agent_id == "agent-1"
assert draft.draft_type == AgentConfigDraftType.DRAFT
assert draft.base_snapshot_id == "version-2"
assert draft.config_snapshot_dict == _agent_soul_with_model().model_dump(mode="json")
assert draft.updated_by == "account-1"
def test_restore_roster_agent_version_rejects_invisible_versions(monkeypatch: pytest.MonkeyPatch):
@ -1998,6 +2374,18 @@ def test_app_list_all_excludes_agent_apps_by_default():
assert "apps.mode != :mode_1" in sql
def test_app_list_agent_mode_requires_visible_roster_backing_agent():
filters = AppService._build_app_list_filters(
"account-1", "tenant-1", AppListParams(mode="agent"), FakeSession(scalar=None, scalars=None)
)
sql = " ".join(str(filter_) for filter_ in filters)
assert "EXISTS" in sql
assert "agents.app_id = apps.id" in sql
assert "agents.scope" in sql
assert "agents.source" in sql
def test_validator_dict_helpers_wrap_validation_errors():
valid_soul = ComposerConfigValidator.validate_agent_soul_dict({"prompt": {"system_prompt": "x"}})
valid_node_job = ComposerConfigValidator.validate_node_job_dict({"workflow_prompt": "x"})
@ -2843,6 +3231,11 @@ class TestWorkflowAgentDraftBindingSync:
)
node_data = graph["nodes"][0]["data"]
assert node_data["agent_binding"] == {
"binding_type": "roster_agent",
"agent_id": "agent-1",
"current_snapshot_id": "snapshot-1",
}
assert node_data["agent_task"] == "Summarize the upstream result."
assert node_data["agent_declared_outputs"][0]["name"] == "summary"
assert node_data["agent_declared_outputs"][0]["type"] == "string"
@ -2852,6 +3245,103 @@ class TestWorkflowAgentDraftBindingSync:
assert profile_output["children"][1]["array_item"]["children"][0]["name"] == "city"
assert "agent_declared_outputs" not in workflow.graph_dict["nodes"][0]["data"]
def test_projects_inline_binding_over_pending_inline_graph_response(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_binding": {
"binding_type": "inline_agent",
},
},
}
],
"edges": [],
}
),
)
binding = WorkflowAgentNodeBinding(
id="binding-1",
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version=Workflow.VERSION_DRAFT,
node_id="agent-node",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id="inline-agent-1",
current_snapshot_id="inline-snapshot-1",
)
session = FakeSession(scalars=[[binding]])
graph = WorkflowAgentPublishService.project_draft_bindings_to_graph(
session=session,
draft_workflow=workflow,
)
assert graph["nodes"][0]["data"]["agent_binding"] == {
"binding_type": "inline_agent",
"agent_id": "inline-agent-1",
"current_snapshot_id": "inline-snapshot-1",
}
assert workflow.graph_dict["nodes"][0]["data"]["agent_binding"] == {
"binding_type": "inline_agent",
}
def test_keeps_pending_inline_graph_response_over_existing_roster_binding(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_binding": {
"binding_type": "inline_agent",
},
},
}
],
"edges": [],
}
),
)
binding = WorkflowAgentNodeBinding(
id="binding-1",
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version=Workflow.VERSION_DRAFT,
node_id="agent-node",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="agent-1",
current_snapshot_id="snapshot-1",
)
session = FakeSession(scalars=[[binding]])
graph = WorkflowAgentPublishService.project_draft_bindings_to_graph(
session=session,
draft_workflow=workflow,
)
assert graph["nodes"][0]["data"]["agent_binding"] == {
"binding_type": "inline_agent",
}
def test_creates_roster_binding_from_agent_node_graph(self):
workflow = Workflow(
id="workflow-1",
@ -2979,6 +3469,52 @@ class TestWorkflowAgentDraftBindingSync:
workflow_prompt="Use the current node context.",
).model_dump(mode="json")
def test_keeps_pending_inline_binding_in_draft_graph_without_db_binding(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_binding": {
"binding_type": "inline_agent",
},
},
}
]
}
),
)
existing_binding = WorkflowAgentNodeBinding(
id="binding-1",
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version=Workflow.VERSION_DRAFT,
node_id="agent-node",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="agent-1",
current_snapshot_id="snapshot-1",
)
session = FakeSession(scalars=[[existing_binding]])
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=session,
draft_workflow=workflow,
account_id="account-1",
)
assert session.deleted == []
assert session.added == []
assert session.flushes == 1
def test_rejects_inline_binding_for_agent_owned_by_another_node(self):
workflow = Workflow(
id="workflow-1",
@ -3058,7 +3594,7 @@ class TestWorkflowAgentDraftBindingSync:
account_id="account-1",
)
def test_rejects_inline_binding_without_current_snapshot_id(self):
def test_treats_partial_inline_binding_as_pending_draft_state(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
@ -3083,12 +3619,17 @@ class TestWorkflowAgentDraftBindingSync:
),
)
with pytest.raises(ValueError, match="inline_agent binding requires current_snapshot_id"):
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=FakeSession(scalars=[[]]),
draft_workflow=workflow,
account_id="account-1",
)
session = FakeSession(scalars=[[]])
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=session,
draft_workflow=workflow,
account_id="account-1",
)
assert session.added == []
assert session.deleted == []
assert session.flushes == 1
def test_rejects_inline_binding_with_missing_snapshot(self):
workflow = Workflow(
@ -3312,20 +3853,151 @@ def test_dataset_rows_filters_malformed_ids(monkeypatch: pytest.MonkeyPatch):
return [], 0
import services.dataset_service as dataset_service_module
from services.agent.knowledge_datasets import get_tenant_knowledge_dataset_rows
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
valid = "550e8400-e29b-41d4-a716-446655440000"
rows = AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid])
rows = get_tenant_knowledge_dataset_rows(tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid])
assert rows == {}
assert captured["ids"] == [valid]
# all-malformed input never touches the DB
captured.clear()
assert AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {}
assert get_tenant_knowledge_dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {}
assert captured == {}
@pytest.mark.parametrize(
("variant", "save_call"),
[
(
ComposerVariant.AGENT_APP,
lambda payload: AgentComposerService.save_agent_app_composer(
tenant_id="tenant-1",
app_id="app-1",
account_id="account-1",
payload=payload,
),
),
(
ComposerVariant.WORKFLOW,
lambda payload: AgentComposerService.save_workflow_composer(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
account_id="account-1",
payload=payload,
),
),
],
)
def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pytest.MonkeyPatch, variant, save_call):
captured = {"calls": 0}
def fake_get_datasets_by_ids(ids, tenant_id):
captured["calls"] += 1
captured["ids"] = ids
captured["tenant_id"] = tenant_id
return [], 0
import services.dataset_service as dataset_service_module
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
payload = ComposerSavePayload.model_validate(
{
"variant": variant.value,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
"soul_lock": {"locked": False},
"agent_soul": {
"knowledge": {
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "not-a-uuid"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
}
},
}
)
with pytest.raises(InvalidComposerConfigError, match="not-a-uuid"):
save_call(payload)
assert captured == {"calls": 0}
@pytest.mark.parametrize(
("variant", "save_call"),
[
(
ComposerVariant.AGENT_APP,
lambda payload: AgentComposerService.save_agent_app_composer(
tenant_id="tenant-1",
app_id="app-1",
account_id="account-1",
payload=payload,
),
),
(
ComposerVariant.WORKFLOW,
lambda payload: AgentComposerService.save_workflow_composer(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
account_id="account-1",
payload=payload,
),
),
],
)
def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
monkeypatch: pytest.MonkeyPatch, variant, save_call
):
captured = {}
missing_dataset_id = "550e8400-e29b-41d4-a716-446655440000"
def fake_get_datasets_by_ids(ids, tenant_id):
captured["ids"] = ids
captured["tenant_id"] = tenant_id
return [], 0
import services.dataset_service as dataset_service_module
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
payload = ComposerSavePayload.model_validate(
{
"variant": variant.value,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
"soul_lock": {"locked": False},
"agent_soul": {
"knowledge": {
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": missing_dataset_id}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
}
},
}
)
with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id):
save_call(payload)
assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"}
def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch: pytest.MonkeyPatch):
"""The slash-menu Tools tab needs both selection granularities: a provider
hosts many tools (like an MCP server), so candidates return one

View File

@ -124,7 +124,18 @@ def _soul() -> AgentSoulConfig:
{"id": "ct-2", "name": "disabled-one", "enabled": False},
],
},
"knowledge": {"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}]},
"knowledge": {
"sets": [
{
"id": "kb-1",
"name": "产品知识",
"description": "knowledge set",
"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
},
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
}
)
@ -143,12 +154,16 @@ def test_soul_candidates_lists_configured_items_only():
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"]
# enriched from DB; dangling dataset kept with missing flag (placeholder, 0522)
knowledge = {item["id"]: item for item in lists["knowledge_datasets"]}
assert knowledge["ds-1"]["name"] == "产品手册"
assert knowledge["ds-1"]["missing"] is False
assert knowledge["ds-gone"]["missing"] is True
assert knowledge["ds-gone"]["name"] == "已删"
# Knowledge mentions point at set ids; nested datasets are hydrated for context.
knowledge_set = lists["knowledge_sets"][0]
assert knowledge_set["id"] == "kb-1"
assert knowledge_set["name"] == "产品知识"
assert knowledge_set["missing_dataset_ids"] == ["ds-gone"]
datasets = {item["id"]: item for item in knowledge_set["datasets"]}
assert datasets["ds-1"]["name"] == "产品手册"
assert datasets["ds-1"]["missing"] is False
assert datasets["ds-gone"]["missing"] is True
assert datasets["ds-gone"]["name"] == "已删"
assert lists["human_contacts"][0]["id"] == "c-1"
assert lists["dify_tools"][0]["id"] == "tavily/tavily_search"

View File

@ -149,22 +149,32 @@ def test_dangling_knowledge_without_label_gets_fallback_name():
]
def test_configured_but_deleted_dataset_surfaces_as_placeholder():
def test_configured_but_deleted_knowledge_set_surfaces_as_placeholder():
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": "see [§knowledge:ds-1:产品手册§]"},
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
"prompt": {"system_prompt": "see [§knowledge:kb-1:产品手册§]"},
"knowledge": {
"sets": [
{
"id": "kb-1",
"name": "产品手册",
"datasets": [{"id": "ds-1", "name": "产品手册"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
},
},
"save_strategy": "save_to_current_version",
}
)
# configured + DB row exists -> clean
assert _findings(payload, existing_dataset_ids={"ds-1"})["knowledge_retrieval_placeholder"] == []
# configured but deleted in DB -> placeholder
assert _findings(payload, existing_dataset_ids=set())["knowledge_retrieval_placeholder"] == [
{"id": "ds-1", "placeholder_name": "产品手册"}
# configured + current Agent Soul row exists -> clean
assert _findings(payload, existing_knowledge_set_ids={"kb-1"})["knowledge_retrieval_placeholder"] == []
# configured but removed from the current Agent Soul surface -> placeholder
assert _findings(payload, existing_knowledge_set_ids=set())["knowledge_retrieval_placeholder"] == [
{"id": "kb-1", "placeholder_name": "产品手册"}
]

View File

@ -107,7 +107,17 @@ def soul() -> AgentSoulConfig:
],
"cli_tools": [{"id": "ct-1", "name": "ffmpeg"}],
},
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
"knowledge": {
"sets": [
{
"id": "kb-1",
"name": "产品手册",
"datasets": [{"id": "ds-1", "name": "产品手册"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
},
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
}
)
@ -117,7 +127,7 @@ def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
resolver = build_soul_mention_resolver(soul)
prompt = (
"Use [§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], "
"ground in [§knowledge:ds-1§], ask [§human:c-1§]."
"ground in [§knowledge:kb-1§], ask [§human:c-1§]."
)
expanded = expand_prompt_mentions(prompt, resolver)

View File

@ -1,13 +1,16 @@
"""Unit tests for the Skill package validator/extractor (ENG-370)."""
"""Unit tests for the Skill package validator/normalizer (ENG-370)."""
from __future__ import annotations
import hashlib
import io
import zipfile
import zlib
import pytest
from services.agent.skill_package_service import SkillPackageError, SkillPackageService
from services.agent import skill_package_service as skill_package_service_module
from services.agent.skill_package_service import NormalizedSkillPackage, SkillPackageError, SkillPackageService
_SKILL_MD = """---
name: PDF Toolkit
@ -28,12 +31,17 @@ def _zip(members: dict[str, bytes], *, compression: int = zipfile.ZIP_DEFLATED)
return buffer.getvalue()
def _extract(members: dict[str, bytes], *, filename: str = "skill.zip"):
return SkillPackageService().validate_and_extract(content=_zip(members), filename=filename)
def _normalize(members: dict[str, bytes], *, filename: str = "skill.zip") -> NormalizedSkillPackage:
return SkillPackageService().validate_and_normalize(content=_zip(members), filename=filename)
def test_valid_skill_extracts_manifest():
manifest = _extract({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"})
def _archive_members(content: bytes) -> list[str]:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
return sorted(info.filename for info in archive.infolist() if not info.is_dir())
def test_valid_skill_normalizes_manifest():
manifest = _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}).manifest
assert manifest.name == "PDF Toolkit"
assert manifest.description == "Tools for working with PDF files."
@ -44,19 +52,101 @@ def test_valid_skill_extracts_manifest():
def test_name_falls_back_to_heading_without_frontmatter():
manifest = _extract({"SKILL.md": b"# Heading Name\n\nbody"})
manifest = _normalize({"SKILL.md": b"# Heading Name\n\nbody"}).manifest
assert manifest.name == "Heading Name"
assert manifest.description == ""
def test_nested_skill_md_is_found():
manifest = _extract({"pdf-toolkit/SKILL.md": _SKILL_MD.encode()})
assert manifest.entry_path == "pdf-toolkit/SKILL.md"
def test_shallowest_skill_md_preferred():
manifest = _extract({"SKILL.md": _SKILL_MD.encode(), "nested/SKILL.md": _SKILL_MD.encode()})
def test_shallowest_skill_md_preferred_during_normalization():
manifest = _normalize({"SKILL.md": _SKILL_MD.encode(), "nested/SKILL.md": _SKILL_MD.encode()}).manifest
assert manifest.entry_path == "SKILL.md"
assert manifest.files == ["SKILL.md", "nested/SKILL.md"]
def test_validate_and_normalize_keeps_root_skill_unchanged():
package = _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"})
assert package.manifest.entry_path == "SKILL.md"
assert package.manifest.files == ["SKILL.md", "scripts/run.py"]
assert package.skill_md_bytes == _SKILL_MD.encode()
assert package.strip_prefix is None
assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"]
assert len(package.manifest.hash) == 64
def test_validate_and_normalize_strips_single_top_level_folder():
package = _normalize(
{
"pdf-toolkit/SKILL.md": _SKILL_MD.encode(),
"pdf-toolkit/scripts/run.py": b"print('hi')\n",
}
)
assert package.manifest.entry_path == "SKILL.md"
assert package.manifest.files == ["SKILL.md", "scripts/run.py"]
assert package.skill_md_bytes == _SKILL_MD.encode()
assert package.strip_prefix == "pdf-toolkit/"
assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"]
def test_validate_and_normalize_strips_single_top_level_folder_ignoring_other_root_entries():
package = _normalize(
{
"pdf-toolkit/SKILL.md": _SKILL_MD.encode(),
"pdf-toolkit/scripts/run.py": b"print('hi')\n",
"README.md": b"bundle notes\n",
}
)
assert package.manifest.entry_path == "SKILL.md"
assert package.manifest.files == ["SKILL.md", "scripts/run.py"]
assert package.skill_md_bytes == _SKILL_MD.encode()
assert package.strip_prefix == "pdf-toolkit/"
assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"]
def test_validate_and_normalize_strips_single_top_level_folder_dropping_nested_foreign_paths():
package = _normalize(
{
"pdf-toolkit/SKILL.md": _SKILL_MD.encode(),
"pdf-toolkit/scripts/run.py": b"print('hi')\n",
"bundle/other.txt": b"x",
}
)
assert package.manifest.entry_path == "SKILL.md"
assert package.manifest.files == ["SKILL.md", "scripts/run.py"]
assert package.skill_md_bytes == _SKILL_MD.encode()
assert package.strip_prefix == "pdf-toolkit/"
assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"]
def test_validate_and_normalize_rejects_multiple_depth_2_skill_roots_with_sibling_skill_tree():
with pytest.raises(SkillPackageError) as exc_info:
_normalize(
{
"pdf-toolkit/SKILL.md": _SKILL_MD.encode(),
"pdf-toolkit/scripts/run.py": b"print('hi')\n",
"other-tool/SKILL.md": _SKILL_MD.encode(),
}
)
assert exc_info.value.code == "files_outside_skill_root"
def test_validate_and_normalize_strips_deeper_selected_skill_root():
members = {
"bundle/pdf-toolkit/SKILL.md": _SKILL_MD.encode(),
"bundle/pdf-toolkit/scripts/run.py": b"print('hi')\n",
}
original_upload_bytes = _zip(members)
package = SkillPackageService().validate_and_normalize(content=original_upload_bytes, filename="skill.zip")
assert package.manifest.entry_path == "SKILL.md"
assert package.manifest.files == ["SKILL.md", "scripts/run.py"]
assert package.strip_prefix == "bundle/pdf-toolkit/"
assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"]
assert package.manifest.hash == hashlib.sha256(package.archive_bytes).hexdigest()
assert package.manifest.hash != hashlib.sha256(original_upload_bytes).hexdigest()
@pytest.mark.parametrize(
@ -71,53 +161,105 @@ def test_shallowest_skill_md_preferred():
)
def test_invalid_packages_rejected(members: dict[str, bytes], filename: str, code: str):
with pytest.raises(SkillPackageError) as exc_info:
_extract(members, filename=filename)
_normalize(members, filename=filename)
assert exc_info.value.code == code
assert exc_info.value.status_code == 400
def test_non_zip_content_rejected():
with pytest.raises(SkillPackageError) as exc_info:
SkillPackageService().validate_and_extract(content=b"not a zip", filename="skill.zip")
SkillPackageService().validate_and_normalize(content=b"not a zip", filename="skill.zip")
assert exc_info.value.code == "invalid_archive"
def test_zip_slip_member_rejected():
payload = _zip({"../evil.txt": b"x", "SKILL.md": _SKILL_MD.encode()})
with pytest.raises(SkillPackageError) as exc_info:
SkillPackageService().validate_and_extract(content=payload, filename="skill.zip")
SkillPackageService().validate_and_normalize(content=payload, filename="skill.zip")
assert exc_info.value.code == "unsafe_path"
def test_empty_archive_rejected():
with pytest.raises(SkillPackageError) as exc_info:
SkillPackageService().validate_and_extract(content=b"", filename="skill.zip")
SkillPackageService().validate_and_normalize(content=b"", filename="skill.zip")
assert exc_info.value.code == "empty_archive"
def test_validate_and_normalize_rejects_skill_md_too_large(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(skill_package_service_module, "_MAX_SKILL_MD_BYTES", 8)
with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": _SKILL_MD.encode()})
assert exc_info.value.code == "skill_md_too_large"
def test_validate_and_normalize_rejects_too_many_entries(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(skill_package_service_module, "_MAX_ENTRIES", 1)
with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('x')\n"})
assert exc_info.value.code == "too_many_entries"
def test_validate_and_normalize_rejects_archive_too_large_uncompressed(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(skill_package_service_module, "_MAX_UNCOMPRESSED_BYTES", 32)
with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"x" * 33})
assert exc_info.value.code == "archive_too_large"
def test_validate_and_normalize_rejects_archive_too_large_uploaded_bytes(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(skill_package_service_module, "_MAX_ARCHIVE_BYTES", 8)
with pytest.raises(SkillPackageError) as exc_info:
SkillPackageService().validate_and_normalize(content=b"x" * 9, filename="skill.zip")
assert exc_info.value.code == "archive_too_large"
def test_bad_frontmatter_yaml_rejected():
bad = b"---\n: : : not yaml\n---\n# x\n"
with pytest.raises(SkillPackageError) as exc_info:
_extract({"SKILL.md": bad})
_normalize({"SKILL.md": bad})
assert exc_info.value.code == "invalid_frontmatter"
def test_unterminated_frontmatter_falls_back_to_heading():
# leading '---' with no closing fence -> no frontmatter, use the heading
manifest = _extract({"SKILL.md": b"---\n# Heading Wins\nbody"})
manifest = _normalize({"SKILL.md": b"---\n# Heading Wins\nbody"}).manifest
assert manifest.name == "Heading Wins"
def test_read_member_bytes_roundtrip_and_errors():
service = SkillPackageService()
payload = _zip({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('x')\n"})
def test_validate_and_normalize_rejects_files_outside_selected_skill_root():
with pytest.raises(SkillPackageError) as exc_info:
_normalize({"bundle/pdf-toolkit/SKILL.md": _SKILL_MD.encode(), "README.md": b"x"})
assert exc_info.value.code == "files_outside_skill_root"
assert service.read_member_bytes(content=payload, member_path="scripts/run.py") == b"print('x')\n"
with pytest.raises(SkillPackageError) as missing:
service.read_member_bytes(content=payload, member_path="nope.txt")
assert missing.value.code == "member_not_found"
def test_validate_and_normalize_rejects_duplicate_normalized_paths():
with pytest.raises(SkillPackageError) as exc_info:
_normalize(
{
"pdf-toolkit/SKILL.md": _SKILL_MD.encode(),
"pdf-toolkit/scripts/run.py": b"print('x')\n",
"pdf-toolkit/scripts/./run.py": b"print('y')\n",
}
)
assert exc_info.value.code == "duplicate_member_path"
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_validate_and_normalize_maps_member_decompression_failures_to_invalid_archive(monkeypatch: pytest.MonkeyPatch):
original_read = zipfile.ZipFile.read
def corrupted_read(self: zipfile.ZipFile, member: str | zipfile.ZipInfo, *args: object, **kwargs: object) -> bytes:
filename = member.filename if isinstance(member, zipfile.ZipInfo) else member
if filename == "scripts/run.py":
raise zlib.error("invalid distance too far back")
return original_read(self, member, *args, **kwargs)
monkeypatch.setattr(zipfile.ZipFile, "read", corrupted_read)
with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('x')\n"})
assert exc_info.value.code == "invalid_archive"
assert exc_info.value.message == "skill archive is not a valid zip"

View File

@ -32,30 +32,16 @@ def test_slugify_skill_name():
assert slugify_skill_name("") == "skill"
def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members():
content = _zip({"SKILL.md": _SKILL_MD, "scripts/run.py": b"print('x')\n"})
def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest():
content = _zip({"pdf-toolkit/SKILL.md": _SKILL_MD, "pdf-toolkit/scripts/run.py": b"print('x')\n"})
tool_files = MagicMock()
tool_files.create_file_by_raw.side_effect = [
SimpleNamespace(id="md-tool-file"),
SimpleNamespace(id="zip-tool-file"),
SimpleNamespace(id="script-tool-file"),
]
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(
@ -66,34 +52,35 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members()
agent_id="agent-1",
)
# ToolFiles: SKILL.md, full archive, and each inspectable package member.
assert tool_files.create_file_by_raw.call_count == 3
md_call, zip_call, script_call = tool_files.create_file_by_raw.call_args_list
# ToolFiles: SKILL.md and the full archive. Archive members stay lazy.
assert tool_files.create_file_by_raw.call_count == 2
md_call, zip_call = tool_files.create_file_by_raw.call_args_list
assert md_call.kwargs["mimetype"] == "text/markdown"
assert md_call.kwargs["file_binary"] == _SKILL_MD
assert zip_call.kwargs["mimetype"] == "application/zip"
assert zip_call.kwargs["file_binary"] == content
assert script_call.kwargs["mimetype"] in {"text/x-python", "text/plain", "application/octet-stream"}
assert script_call.kwargs["file_binary"] == b"print('x')\n"
assert script_call.kwargs["filename"] == "run.py"
assert zip_call.kwargs["file_binary"] != content
with zipfile.ZipFile(io.BytesIO(zip_call.kwargs["file_binary"])) as archive:
assert sorted(info.filename for info in archive.infolist() if not info.is_dir()) == [
"SKILL.md",
"scripts/run.py",
]
# Committed as drive-owned with the standardized keys.
# Committed as drive-owned with the standardized keys. Member paths are
# carried in metadata for inspect/preview/runtime lazy resolution.
commit_kwargs = drive.commit.call_args.kwargs
assert commit_kwargs["agent_id"] == "agent-1"
items = commit_kwargs["items"]
assert [item.key for item in items] == [
"pdf-toolkit/SKILL.md",
"pdf-toolkit/.DIFY-SKILL-FULL.zip",
"pdf-toolkit/scripts/run.py",
]
assert all(item.value_owned_by_drive for item in items)
assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file", "script-tool-file"]
assert [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
assert items[2].is_skill is False
# The returned upload response carries only the drive-derived fields the UI needs.
skill = result["skill"]
@ -101,4 +88,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members()
assert skill["name"] == "PDF Toolkit"
assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
assert result["manifest"]["entry_path"] == "SKILL.md"
assert result["manifest"]["files"] == ["SKILL.md", "scripts/run.py"]
drive.list_skills.assert_not_called()
assert "_committed_items" not in result

View File

@ -7,6 +7,8 @@ exercised against the project's in-memory SQLite engine with seeded ToolFiles.
from __future__ import annotations
import datetime
import io
import zipfile
from collections.abc import Generator
from unittest.mock import patch
@ -103,6 +105,14 @@ def _seed_tool_file(*, user_id: str = USER, name: str = "f.txt") -> str:
return tool_file.id
def _zip_bytes(members: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for name, data in members.items():
archive.writestr(name, data)
return buffer.getvalue()
def _commit(key: str, tool_file_id: str, *, owned: bool = True):
return AgentDriveService().commit(
tenant_id=TENANT,
@ -769,7 +779,8 @@ def test_inspect_skill_returns_manifest_files_and_file_tree():
assert result["warnings"] == []
assert [file["path"] for file in result["files"]] == ["SKILL.md", "references/guide.md", "scripts/run.py"]
assert result["files"][0]["available_in_drive"] is True
assert result["files"][1]["available_in_drive"] is False
assert result["files"][1]["available_in_drive"] is True
assert result["files"][1]["drive_key"] == "pdf-toolkit/references/guide.md"
assert result["file_tree"][0]["name"] == "references"
assert result["file_tree"][1]["name"] == "scripts"
assert result["file_tree"][2]["name"] == "SKILL.md"
@ -787,6 +798,48 @@ def test_inspect_skill_falls_back_to_drive_keys_when_manifest_missing():
assert [file["path"] for file in result["files"]] == ["SKILL.md"]
def test_preview_skill_archive_member_from_manifest_without_drive_row():
_commit_skill(manifest_files=["SKILL.md", "references/guide.md"])
archive = _zip_bytes({"SKILL.md": b"# PDF Toolkit\n", "references/guide.md": b"Guide content\n"})
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([archive])
result = AgentDriveService().preview(
tenant_id=TENANT,
agent_id=AGENT,
key="pdf-toolkit/references/guide.md",
)
assert result == {
"key": "pdf-toolkit/references/guide.md",
"size": len(b"Guide content\n"),
"truncated": False,
"binary": False,
"text": "Guide content\n",
}
def test_download_url_signs_skill_archive_member_from_manifest_without_drive_row():
_commit_skill(manifest_files=["SKILL.md", "references/guide.md"])
with patch.object(
AgentDriveService,
"sign_archive_member_url",
return_value="https://signed.example/member",
) as sign:
url = AgentDriveService().download_url(
tenant_id=TENANT,
agent_id=AGENT,
key="pdf-toolkit/references/guide.md",
)
assert url == "https://signed.example/member"
kwargs = sign.call_args.kwargs
assert kwargs["key"] == "pdf-toolkit/references/guide.md"
assert kwargs["member_path"] == "references/guide.md"
assert kwargs["for_external"] is True
def test_skill_metadata_rejects_non_canonical_rows():
tf = _seed_tool_file(name="not-skill.md")
with pytest.raises(AgentDriveError) as exc_info:

View File

@ -98,6 +98,8 @@ class TestInnerKnowledgeRetrievalService:
"total_price": "0",
"currency": "USD",
"latency": 0,
"time_to_first_token": None,
"time_to_generate": None,
}
mock_rag_cls.return_value = rag

1
api/uv.lock generated
View File

@ -1299,6 +1299,7 @@ requires-dist = [
{ name = "httpx", specifier = "==0.28.1" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
{ name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" },
{ name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" },
{ name = "protobuf", marker = "extra == 'grpc'", specifier = ">=6.33.5,<7.0.0" },
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
{ name = "pydantic-ai-slim", specifier = ">=1.85.1,<2.0.0" },

View File

@ -23,6 +23,7 @@ server = [
"graphon==0.5.2",
"jsonschema>=4.23.0,<5.0.0",
"jwcrypto>=1.5.6,<2",
"logfire[fastapi,httpx,redis]>=4.37.0,<5.0.0",
"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",

View File

@ -0,0 +1,169 @@
"""Shared drive download materialization helpers.
This module centralizes the safety-critical filesystem logic used by both the
sandbox-visible CLI and the runtime drive layer. It owns path resolution under
one local drive base, overwrite-via-temp-file semantics, payload size checks,
and safe extraction of downloaded skill archives so those invariants cannot
drift between the two call sites.
"""
from __future__ import annotations
import stat
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
from typing import Final
from uuid import uuid4
from zipfile import BadZipFile, ZipFile, ZipInfo
SKILL_ARCHIVE_FILENAME: Final[str] = ".DIFY-SKILL-FULL.zip"
@dataclass(frozen=True, slots=True)
class DriveDownloadPayload:
"""One downloaded drive payload ready to materialize under a local base."""
key: str
payload: bytes
size: int | None = None
class DriveMaterializationValidationError(ValueError):
"""Raised when one drive key or archive entry is structurally unsafe."""
class DriveMaterializationTransferError(RuntimeError):
"""Raised when one downloaded payload cannot be safely materialized."""
def materialize_drive_downloads(
*,
base_path: Path,
downloads: list[DriveDownloadPayload],
) -> list[Path]:
"""Write downloaded drive payloads under one local base and extract skills.
The helper preserves caller-provided order in the returned list of paths.
Skill archives are extracted and deleted only after every payload has been
written successfully so partial extraction cannot outlive a later failure in
the same batch. The returned path for an archive is the path where it was
downloaded before successful extraction.
"""
resolved_base_path = base_path.expanduser().resolve()
try:
_ = resolved_base_path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise DriveMaterializationTransferError(f"failed to prepare drive base {resolved_base_path}") from exc
written_paths: list[Path] = []
archive_paths: list[Path] = []
for download in downloads:
if download.size is not None and len(download.payload) != download.size:
raise DriveMaterializationTransferError(f"downloaded drive file size mismatch for {download.key}")
destination = resolve_drive_destination(resolved_base_path, download.key)
try:
destination.parent.mkdir(parents=True, exist_ok=True)
temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}")
_ = temp_path.write_bytes(download.payload)
_ = temp_path.replace(destination)
except OSError as exc:
raise DriveMaterializationTransferError(f"failed to materialize drive file {download.key}") from exc
written_paths.append(destination)
if destination.name == SKILL_ARCHIVE_FILENAME:
archive_paths.append(destination)
for archive_path in sorted(archive_paths):
extract_skill_archive(archive_path)
_delete_extracted_archive(archive_path)
return written_paths
def resolve_drive_destination(base_path: Path, drive_key: str) -> Path:
"""Resolve one drive key under a local base and reject path traversal."""
destination = (base_path / Path(drive_key)).resolve()
try:
destination.relative_to(base_path)
except ValueError as exc:
raise DriveMaterializationValidationError(f"drive key resolves outside the drive base: {drive_key}") from exc
return destination
def extract_skill_archive(archive_path: Path) -> None:
"""Safely extract one downloaded skill archive into its containing directory."""
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():
destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename)
if _is_zip_symlink(zip_info):
raise DriveMaterializationValidationError(
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 DriveMaterializationValidationError:
raise
except (BadZipFile, OSError) as exc:
raise DriveMaterializationTransferError(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 DriveMaterializationValidationError(f"skill archive contains unsafe absolute path: {entry_name}")
if any(part in {"", ".", ".."} for part in pure_path.parts):
raise DriveMaterializationValidationError(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 DriveMaterializationValidationError(
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 stat.S_ISLNK(file_mode)
def _delete_extracted_archive(archive_path: Path) -> None:
try:
archive_path.unlink(missing_ok=True)
except OSError as exc:
raise DriveMaterializationTransferError(
f"failed to delete extracted skill archive: {archive_path.name}"
) from exc
__all__ = [
"DriveDownloadPayload",
"DriveMaterializationTransferError",
"DriveMaterializationValidationError",
"SKILL_ARCHIVE_FILENAME",
"extract_skill_archive",
"materialize_drive_downloads",
"resolve_drive_destination",
]

View File

@ -10,14 +10,23 @@ 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 pathlib import Path
from tempfile import TemporaryDirectory
from uuid import uuid4
from zipfile import BadZipFile, ZIP_DEFLATED, ZipFile, ZipInfo
from typing import ClassVar, Literal
from zipfile import ZIP_DEFLATED, ZipFile
from pydantic import BaseModel, ConfigDict
from dify_agent.agent_stub._drive_materialization import (
DriveDownloadPayload,
DriveMaterializationTransferError,
DriveMaterializationValidationError,
SKILL_ARCHIVE_FILENAME,
materialize_drive_downloads,
resolve_drive_destination,
)
from dify_agent.agent_stub.cli._env import read_agent_stub_environment
from dify_agent.agent_stub.cli._files import upload_tool_file_resource_from_environment
from dify_agent.agent_stub.client._agent_stub import (
@ -37,11 +46,11 @@ from dify_agent.agent_stub.protocol.agent_stub import (
)
_SKILL_MD_FILENAME = "SKILL.md"
_SKILL_ARCHIVE_FILENAME = ".DIFY-SKILL-FULL.zip"
_SKIP_DIR_NAMES = frozenset(
{".git", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".venv", "node_modules"}
)
_SKIP_FILE_NAMES = frozenset({".DS_Store", _SKILL_ARCHIVE_FILENAME})
_SKIP_FILE_NAMES = frozenset({".DS_Store", SKILL_ARCHIVE_FILENAME})
DrivePushKind = Literal["file", "skill", "dir"]
@dataclass(frozen=True, slots=True)
@ -52,18 +61,28 @@ class _DriveUploadItem:
drive_key: str
def list_drive_from_environment(prefix: str, json_output: bool) -> str | AgentStubDriveManifestResponse:
class DrivePullResult(BaseModel):
"""Structured JSON result for ``dify-agent drive pull --json``."""
class Item(BaseModel):
key: str
local_path: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
items: list[Item]
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
def list_drive_manifest_from_environment(prefix: str) -> AgentStubDriveManifestResponse:
"""List drive items through the Agent Stub using the current environment.
Args:
prefix: Optional drive-key prefix forwarded to the manifest request.
json_output: When ``True``, return the validated manifest response model.
When ``False``, return one human-readable tab-separated line per item
containing size, mime type, hash, and key.
Returns:
Either ``AgentStubDriveManifestResponse`` for JSON callers or a formatted
string for human-facing CLI output.
The validated manifest response model.
Side effects:
Calls the Agent Stub drive manifest control-plane endpoint with
@ -78,24 +97,24 @@ def list_drive_from_environment(prefix: str, json_output: bool) -> str | AgentSt
prefix=prefix,
include_download_url=False,
)
if json_output:
return response
return _format_manifest(response)
return response
def pull_drive_from_environment(
targets: list[str] | None = None,
drive_base: str = DEFAULT_AGENT_STUB_DRIVE_BASE,
) -> list[Path]:
local_base: str | None = None,
) -> DrivePullResult:
"""Pull drive files into one local drive base via signed download URLs.
Args:
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.
local_base: Local base directory that receives downloaded drive files.
When omitted, the historical Agent Stub drive base is used.
Returns:
A list of written local paths under ``drive_base``.
A structured JSON-ready result with requested drive targets/prefixes
that matched at least one manifest item and their local paths.
Observable behavior:
Requests a manifest with ``include_download_url=True``, requires every
@ -107,10 +126,12 @@ def pull_drive_from_environment(
``.DIFY-SKILL-FULL.zip`` archives into their containing skill
directory with the same path-safety checks. Archive extraction is staged
under a temporary directory and only moved into place after the full
archive validates successfully.
archive validates successfully. Successfully extracted skill archives
are deleted from disk.
The return value remains the list of downloaded paths only; extracted
files are materialized on disk but are not added to the returned list.
Downloaded files and extracted files are materialized on disk but are
not enumerated in the returned item list; prefix pulls return the local
path corresponding to the requested prefix.
Raises:
AgentStubValidationError: if a manifest item omits ``download_url``, a
@ -124,48 +145,68 @@ def pull_drive_from_environment(
environment = read_agent_stub_environment()
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,
)
def _fetch_manifest(target: str) -> AgentStubDriveManifestResponse:
return request_agent_stub_drive_manifest_sync(
url=environment.url,
auth_jwe=environment.auth_jwe,
prefix=target,
include_download_url=True,
)
base_path = Path(drive_base).expanduser().resolve()
base_path.mkdir(parents=True, exist_ok=True)
written_paths: list[Path] = []
with ThreadPoolExecutor(max_workers=min(len(manifest_targets), 4)) as executor:
responses = list(executor.map(_fetch_manifest, manifest_targets))
downloads: list[DriveDownloadPayload] = []
resolved_base_path = Path(local_base or DEFAULT_AGENT_STUB_DRIVE_BASE).expanduser().resolve()
result_items: list[DrivePullResult.Item] = []
seen_result_targets: set[str] = set()
for target, response in zip(manifest_targets, responses, strict=True):
if not response.items or target in seen_result_targets:
continue
seen_result_targets.add(target)
try:
local_path = resolve_drive_destination(resolved_base_path, target)
except DriveMaterializationValidationError as exc:
raise AgentStubValidationError(str(exc)) from exc
result_items.append(DrivePullResult.Item(key=target, local_path=str(local_path)))
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}")
destination = _resolve_drive_destination(base_path, item.key)
try:
_ = resolve_drive_destination(resolved_base_path, item.key)
except DriveMaterializationValidationError as exc:
raise AgentStubValidationError(str(exc)) from exc
payload = download_file_bytes_from_signed_url_sync(download_url=download_url)
if item.size is not None and len(payload) != item.size:
raise AgentStubTransferError(f"downloaded drive file size mismatch for {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)
written_paths.append(destination)
if destination.name == _SKILL_ARCHIVE_FILENAME:
_extract_skill_archive(destination)
return written_paths
downloads.append(DriveDownloadPayload(key=item.key, payload=payload, size=item.size))
try:
_ = materialize_drive_downloads(
base_path=resolved_base_path,
downloads=downloads,
)
except DriveMaterializationValidationError as exc:
raise AgentStubValidationError(str(exc)) from exc
except DriveMaterializationTransferError as exc:
raise AgentStubTransferError(str(exc)) from exc
return DrivePullResult(items=result_items)
def push_drive_from_environment(local_path: str, drive_path: str, recursive: bool) -> AgentStubDriveCommitResponse:
def push_drive_from_environment(
local_path: str,
drive_path: str,
*,
kind: DrivePushKind | None,
) -> AgentStubDriveCommitResponse:
"""Upload local files through the Agent Stub and commit them into the drive.
Args:
local_path: Source file or directory in the sandbox filesystem.
drive_path: Destination drive key or drive-key prefix.
recursive: Select directory mode. ``False`` standardizes skill
directories, while ``True`` uploads raw directory contents.
kind: Optional public upload mode. Files infer file mode when omitted,
while directories require explicit ``skill`` or ``dir`` selection.
Returns:
The validated drive commit response returned by the Agent Stub.
@ -173,25 +214,40 @@ def push_drive_from_environment(local_path: str, drive_path: str, recursive: boo
Mode split:
* If ``local_path`` is a file, upload that file and commit exactly one
``tool_file`` binding to ``drive_path``.
* If ``local_path`` is a directory and ``recursive`` is ``False``,
* If ``local_path`` is a directory and ``kind`` is ``"skill"``,
require ``SKILL.md`` and standardize the upload into
``<drive_path>/SKILL.md`` plus ``<drive_path>/.DIFY-SKILL-FULL.zip``.
* If ``local_path`` is a directory and ``recursive`` is ``True``, upload
* If ``local_path`` is a directory and ``kind`` is ``"dir"``, upload
each regular file under ``drive_path/<relative_path>`` without skill
standardization.
Observable safety behavior:
Rejects missing local paths, rejects recursive directory pushes with no
regular files, and rejects symlinked or escaping paths while preparing
directory uploads or skill archives.
Rejects missing local paths, rejects directory pushes without an
explicit mode, rejects raw directory pushes with no regular files, and
rejects symlinked or escaping paths, including symlinked top-level
``local_path`` roots, while preparing directory uploads or skill
archives.
"""
source_path = Path(local_path).expanduser().resolve()
source_path = Path(local_path).expanduser()
if kind not in {None, "file", "skill", "dir"}:
raise AgentStubValidationError(f"invalid drive push kind: {kind}")
if source_path.is_symlink():
raise AgentStubValidationError(f"drive push does not support symlinked local paths: {source_path}")
source_path = source_path.resolve()
if source_path.is_file():
if kind == "skill":
raise AgentStubValidationError("--kind skill requires a directory containing SKILL.md")
if kind == "dir":
raise AgentStubValidationError("--kind dir requires a directory")
return _commit_uploaded_items([_prepare_uploaded_file(source_path, drive_path)])
if not source_path.is_dir():
raise AgentStubValidationError(f"local path not found: {source_path}")
if recursive:
if kind is None:
raise AgentStubValidationError("directory drive push requires --kind skill or --kind dir")
if kind == "file":
raise AgentStubValidationError("--kind file requires a file")
if kind == "dir":
upload_items = [
_prepare_uploaded_file(path, _join_drive_key(drive_path, relative_path))
for path, relative_path in _iter_regular_files(source_path)
@ -205,14 +261,14 @@ def push_drive_from_environment(local_path: str, drive_path: str, recursive: boo
def _push_skill_directory(source_path: Path, drive_path: str) -> AgentStubDriveCommitResponse:
skill_md_path = source_path / _SKILL_MD_FILENAME
if not skill_md_path.is_file():
raise AgentStubValidationError(f"non-recursive drive push requires {_SKILL_MD_FILENAME}: {source_path}")
raise AgentStubValidationError("--kind skill requires a directory containing SKILL.md")
with TemporaryDirectory() as temp_dir:
archive_path = Path(temp_dir) / _SKILL_ARCHIVE_FILENAME
archive_path = Path(temp_dir) / SKILL_ARCHIVE_FILENAME
_build_skill_archive(source_path, archive_path)
return _commit_uploaded_items(
[
_prepare_uploaded_file(skill_md_path.resolve(), _join_drive_key(drive_path, _SKILL_MD_FILENAME)),
_prepare_uploaded_file(archive_path, _join_drive_key(drive_path, _SKILL_ARCHIVE_FILENAME)),
_prepare_uploaded_file(archive_path, _join_drive_key(drive_path, SKILL_ARCHIVE_FILENAME)),
]
)
@ -239,7 +295,7 @@ def _commit_uploaded_items(items: list[_DriveUploadItem]) -> AgentStubDriveCommi
)
def _format_manifest(response: AgentStubDriveManifestResponse) -> str:
def format_drive_manifest(response: AgentStubDriveManifestResponse) -> str:
return "\n".join(_format_manifest_item(item) for item in response.items)
@ -250,15 +306,6 @@ def _format_manifest_item(item: AgentStubDriveItem) -> str:
return f"{size}\t{mime_type}\t{item_hash}\t{item.key}"
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 AgentStubValidationError(f"drive key resolves outside the drive base: {drive_key}") from exc
return destination
def _iter_regular_files(root_path: Path) -> list[tuple[Path, str]]:
"""Return all regular files under one directory, rejecting unsafe symlinks."""
@ -305,82 +352,6 @@ def _build_skill_archive(source_path: Path, archive_path: Path) -> None:
archive.write(file_path, arcname=relative_path)
def _extract_skill_archive(archive_path: Path) -> None:
"""Safely extract one downloaded skill archive into its containing directory.
Extraction is staged under a temporary directory created inside the target
skill directory. Every entry is validated and materialized into staging
first, and only after the full archive succeeds are staged files moved into
their final locations under the skill directory. Existing files at those
final locations are overwritten in place by the extracted archive content.
Error mapping is intentionally stable for CLI callers: unsafe archive entry
names raise ``AgentStubValidationError``, while malformed archives and zip
parsing / archive I/O failures are translated into ``AgentStubTransferError``.
"""
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():
destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename)
if _is_zip_symlink(zip_info):
raise AgentStubValidationError(
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 AgentStubValidationError:
raise
except (BadZipFile, OSError) as exc:
raise AgentStubTransferError(f"downloaded skill archive is invalid: {archive_path.name}") from exc
def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path:
"""Resolve one zip entry path under a target skill directory.
Zip metadata may contain POSIX or backslash-separated names, so entry names
are normalized to forward slashes before validation. The resolved entry must
not be absolute, empty, ``.`` / ``..`` based, or otherwise escape the target
skill directory after resolution.
"""
normalized_name = entry_name.replace("\\", "/")
pure_path = PurePosixPath(normalized_name)
if not normalized_name or normalized_name.startswith("/") or pure_path.is_absolute():
raise AgentStubValidationError(f"skill archive contains unsafe absolute path: {entry_name}")
if any(part in {"", ".", ".."} for part in pure_path.parts):
raise AgentStubValidationError(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 AgentStubValidationError(
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 stat.S_ISLNK(file_mode)
def _join_drive_key(base_key: str, child_key: str) -> str:
stripped_base = base_key.rstrip("/")
stripped_child = child_key.lstrip("/")
@ -388,7 +359,10 @@ def _join_drive_key(base_key: str, child_key: str) -> str:
__all__ = [
"list_drive_from_environment",
"DrivePullResult",
"DrivePushKind",
"format_drive_manifest",
"list_drive_manifest_from_environment",
"pull_drive_from_environment",
"push_drive_from_environment",
]

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import mimetypes
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, cast
from typing import ClassVar, Literal, cast
from pydantic import BaseModel, ConfigDict, ValidationError
@ -26,7 +26,7 @@ class UploadedToolFileMapping(BaseModel):
transfer_method: Literal["tool_file"] = "tool_file"
reference: str
model_config = ConfigDict(extra="forbid")
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@dataclass(frozen=True, slots=True)
@ -76,12 +76,14 @@ def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFile
environment = read_agent_stub_environment()
filename = source_path.name
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
upload_request = request_agent_stub_file_upload_sync(
upload_request: object = request_agent_stub_file_upload_sync(
url=environment.url,
auth_jwe=environment.auth_jwe,
filename=filename,
mimetype=mime_type,
)
if not hasattr(upload_request, "upload_url") or not isinstance(upload_request.upload_url, str):
raise AgentStubTransferError("signed file upload response is missing upload_url")
with source_path.open("rb") as file_obj:
payload = upload_file_to_signed_url_sync(
upload_url=upload_request.upload_url,
@ -94,19 +96,65 @@ def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFile
def download_file_from_environment(
*,
transfer_method: str,
reference_or_url: str,
directory: str | None = None,
transfer_method: str | None = None,
reference_or_url: str | None = None,
mapping: str | None = None,
local_dir: str | None = None,
) -> DownloadedFileResult:
"""Download one workflow file mapping into the sandbox filesystem."""
"""Download one workflow file mapping into the sandbox filesystem.
Callers may provide either the public positional pair
``TRANSFER_METHOD REFERENCE_OR_URL`` or one JSON ``--mapping`` payload.
The helper normalizes both forms into ``AgentStubFileMapping`` before
requesting a signed download URL from the Agent Stub.
"""
file_mapping = _build_download_mapping(
transfer_method=transfer_method,
reference_or_url=reference_or_url,
mapping=mapping,
)
environment = read_agent_stub_environment()
download_request: object = request_agent_stub_file_download_sync(
url=environment.url,
auth_jwe=environment.auth_jwe,
file=file_mapping,
)
if not hasattr(download_request, "filename") or not isinstance(download_request.filename, str):
raise AgentStubTransferError("signed file download response is missing filename")
if not hasattr(download_request, "download_url") or not isinstance(download_request.download_url, str):
raise AgentStubTransferError("signed file download response is missing download_url")
target_dir = Path(local_dir).expanduser().resolve() if local_dir else Path.cwd()
target_dir.mkdir(parents=True, exist_ok=True)
destination = _deduplicate_destination_path(target_dir / _sanitize_download_filename(download_request.filename))
_ = destination.write_bytes(download_file_bytes_from_signed_url_sync(download_url=download_request.download_url))
return DownloadedFileResult(path=destination)
def _build_download_mapping(
*,
transfer_method: str | None,
reference_or_url: str | None,
mapping: str | None,
) -> AgentStubFileMapping:
if mapping is not None:
if transfer_method is not None or reference_or_url is not None:
raise AgentStubValidationError("--mapping cannot be combined with TRANSFER_METHOD or REFERENCE_OR_URL")
try:
return AgentStubFileMapping.model_validate_json(mapping)
except ValidationError as exc:
raise AgentStubValidationError("invalid file download mapping") from exc
if transfer_method is None or reference_or_url is None:
raise AgentStubValidationError("file download requires either --mapping or TRANSFER_METHOD REFERENCE_OR_URL")
normalized_transfer_method = cast(
Literal["local_file", "tool_file", "datasource_file", "remote_url"],
transfer_method,
)
try:
file_mapping = AgentStubFileMapping(
return AgentStubFileMapping(
transfer_method=normalized_transfer_method,
url=reference_or_url if normalized_transfer_method == "remote_url" else None,
reference=reference_or_url if normalized_transfer_method != "remote_url" else None,
@ -114,17 +162,6 @@ def download_file_from_environment(
except ValidationError as exc:
raise AgentStubValidationError("invalid file download arguments") from exc
download_request = request_agent_stub_file_download_sync(
url=environment.url,
auth_jwe=environment.auth_jwe,
file=file_mapping,
)
target_dir = Path(directory).expanduser().resolve() if directory else Path.cwd()
target_dir.mkdir(parents=True, exist_ok=True)
destination = _deduplicate_destination_path(target_dir / _sanitize_download_filename(download_request.filename))
destination.write_bytes(download_file_bytes_from_signed_url_sync(download_url=download_request.download_url))
return DownloadedFileResult(path=destination)
def _normalize_uploaded_tool_file_resource(payload: dict[str, object]) -> UploadedToolFileResource:
reference = payload.get("reference")

View File

@ -11,13 +11,16 @@ does not pull in FastAPI, Redis, shellctl, or JWE runtime dependencies.
from __future__ import annotations
import sys
from typing import cast
import typer
from typer.main import get_command
from dify_agent.agent_stub.cli._agent_stub import connect_from_environment
from dify_agent.agent_stub.cli._drive import (
list_drive_from_environment,
DrivePushKind,
format_drive_manifest,
list_drive_manifest_from_environment,
pull_drive_from_environment,
push_drive_from_environment,
)
@ -60,21 +63,23 @@ def upload(path: str = typer.Argument(..., metavar="PATH")) -> None:
@file_app.command("download")
def download(
transfer_method: str = typer.Argument(..., metavar="TRANSFER_METHOD"),
reference_or_url: str = typer.Argument(..., metavar="REFERENCE_OR_URL"),
directory: str | None = typer.Argument(default=None, metavar="DIR"),
transfer_method: str | None = typer.Argument(None, metavar="TRANSFER_METHOD"),
reference_or_url: str | None = typer.Argument(None, metavar="REFERENCE_OR_URL"),
mapping: str | None = typer.Option(None, "--mapping", help="Download one file from a mapping JSON object."),
local_dir: str | None = typer.Option(None, "--to", help="Local directory for the downloaded file."),
) -> None:
"""Download one workflow file mapping into the local sandbox directory."""
_run_file_download(
transfer_method=transfer_method,
reference_or_url=reference_or_url,
directory=directory,
mapping=mapping,
local_dir=local_dir,
)
@drive_app.command("list")
def drive_list(
path_prefix: str = typer.Argument("", metavar="PATH_PREFIX"),
path_prefix: str = typer.Argument("", metavar="REMOTE_PREFIX"),
json_output: bool = typer.Option(False, "--json", help="Emit the drive manifest as JSON."),
) -> None:
"""List drive files visible to the current sandbox execution."""
@ -83,32 +88,39 @@ def drive_list(
@drive_app.command("pull")
def drive_pull(
targets: list[str] = typer.Argument(None, metavar="TARGET"),
drive_base: str | None = typer.Option(
targets: list[str] = typer.Argument(None, metavar="REMOTE"),
local_base: str | None = typer.Option(
None,
"--drive-base",
"--to",
help=(
f"Local base directory for pulled drive files. Defaults to ${AGENT_STUB_DRIVE_BASE_ENV_VAR} "
f"or {DEFAULT_AGENT_STUB_DRIVE_BASE}."
),
),
json_output: bool = typer.Option(False, "--json", help="Emit the pull result as JSON."),
) -> None:
"""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)
_run_drive_pull(targets=targets or None, local_base=local_base, json_output=json_output)
@drive_app.command("push")
def drive_push(
local_path: str = typer.Argument(..., metavar="LOCAL_PATH"),
drive_path: str = typer.Argument(..., metavar="DRIVE_PATH"),
recursive: bool = typer.Option(False, "-r", "--recursive", help="Recursively upload directory contents."),
drive_path: str = typer.Argument(..., metavar="REMOTE_PATH"),
kind: str | None = typer.Option(None, "--kind", help="Directory upload kind: skill or dir."),
json_output: bool = typer.Option(
False,
"--json",
help="Accepted for consistency; drive push output is already emitted as JSON.",
),
) -> None:
"""Upload one local file or directory into the agent drive."""
_run_drive_push(local_path=local_path, drive_path=drive_path, recursive=recursive)
del json_output
_run_drive_push(local_path=local_path, drive_path=drive_path, kind=kind)
def main(argv: list[str] | None = None) -> None:
@ -190,12 +202,19 @@ def _run_file_upload(*, path: str) -> None:
typer.echo(response.model_dump_json())
def _run_file_download(*, transfer_method: str, reference_or_url: str, directory: str | None) -> None:
def _run_file_download(
*,
transfer_method: str | None,
reference_or_url: str | None,
mapping: str | None,
local_dir: str | None,
) -> None:
try:
response = download_file_from_environment(
transfer_method=transfer_method,
reference_or_url=reference_or_url,
directory=directory,
mapping=mapping,
local_dir=local_dir,
)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
@ -208,7 +227,7 @@ def _run_file_download(*, transfer_method: str, reference_or_url: str, directory
def _run_drive_list(*, path_prefix: str, json_output: bool) -> None:
try:
response = list_drive_from_environment(prefix=path_prefix, json_output=json_output)
response = list_drive_manifest_from_environment(prefix=path_prefix)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
@ -216,29 +235,34 @@ def _run_drive_list(*, path_prefix: str, json_output: bool) -> None:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
if json_output:
if isinstance(response, str):
raise RuntimeError("drive list JSON output expected a manifest response")
typer.echo(response.model_dump_json())
return
typer.echo(response)
typer.echo(format_drive_manifest(response))
def _run_drive_pull(*, targets: list[str] | None, drive_base: str | None) -> None:
def _run_drive_pull(*, targets: list[str] | None, local_base: str | None, json_output: bool) -> None:
try:
response = pull_drive_from_environment(targets=targets, drive_base=drive_base or read_agent_stub_drive_base())
response = pull_drive_from_environment(targets=targets, local_base=local_base or read_agent_stub_drive_base())
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
for path in response:
typer.echo(str(path))
if json_output:
typer.echo(response.model_dump_json())
return
for item in response.items:
typer.echo(item.local_path)
def _run_drive_push(*, local_path: str, drive_path: str, recursive: bool) -> None:
def _run_drive_push(*, local_path: str, drive_path: str, kind: str | None) -> None:
try:
response = push_drive_from_environment(local_path=local_path, drive_path=drive_path, recursive=recursive)
response = push_drive_from_environment(
local_path=local_path,
drive_path=drive_path,
kind=cast(DrivePushKind | None, kind),
)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc

View File

@ -267,7 +267,11 @@ class AgentStubDriveCommitRequest(BaseModel):
class AgentStubDriveItem(BaseModel):
"""One manifest or commit item returned by the Agent Stub drive API."""
"""One manifest or commit item returned by the Agent Stub drive API.
Known stable fields stay typed, while extra response metadata from the Dify
API is preserved for forward compatibility.
"""
key: str
size: int | None = None
@ -282,7 +286,7 @@ class AgentStubDriveItem(BaseModel):
is_skill: bool | None = None
skill_metadata: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow")
class AgentStubDriveManifestResponse(BaseModel):

View File

@ -6,7 +6,9 @@ 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).
by running sandbox-visible ``dify-agent drive pull`` commands through the shell
layer so materialized files live in the same filesystem that model shell jobs
use.
"""
from typing import Final

View File

@ -1,32 +1,49 @@
"""Runtime Dify drive layer with eager pull for prompt-mentioned targets.
"""Runtime Dify drive layer with shell-backed eager pulls.
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.
those mentioned skills/files through the already-active shell layer by running
the sandbox-visible ``dify-agent drive pull`` command, then contributes a
concise prompt block describing what was loaded. It also contributes a suffix
prompt with the remaining skill catalog plus ``dify-agent drive`` and
``dify-agent file`` usage so the model has concrete Agent Stub commands for
materializing drive content and workflow files.
"""
from __future__ import annotations
import asyncio
import shlex
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
from pathlib import Path
from typing import ClassVar
import httpx
from typing_extensions import Self, override
from agenton.layers import EmptyRuntimeState, Layer, LayerDeps, PlainLayer
from agenton.layers import EmptyRuntimeState, 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
from dify_agent.layers.shell.layer import DifyShellLayer
_SKILL_ARCHIVE_FILENAME = ".DIFY-SKILL-FULL.zip"
_DOWNLOAD_CONCURRENCY = 4
_AGENT_STUB_CLI_USAGE_PROMPT = """Agent Stub CLI usage is available inside shell jobs:
Drive assets are Agent Soul versioned assets:
- List drive assets: `dify-agent drive list [REMOTE_PREFIX]`
- Pull drive assets: `dify-agent drive pull [REMOTE ...] [--to LOCAL_DIR]`
With no remote, pulls the whole visible drive. Pull overwrites local files.
Defaults to `$DIFY_AGENT_STUB_DRIVE_BASE`; use `--to .` for cwd.
`--to` is a local root; remote keys keep their path under it.
Skill archives are automatically extracted after pull.
- Push one file: `dify-agent drive push LOCAL_FILE REMOTE_PATH`
- Push a skill package: `dify-agent drive push LOCAL_DIR REMOTE_PATH --kind skill`
- Push a raw directory: `dify-agent drive push LOCAL_DIR REMOTE_PATH --kind dir`
Workflow file mappings:
- Download a mapping: `dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [--to LOCAL_DIR]`
- Or pass a mapping object: `dify-agent file download --mapping '{"transfer_method":"tool_file","reference":"..."}'`
- Upload an output file: `dify-agent file upload PATH`
Prints JSON like `{"transfer_method":"tool_file","reference":"..."}`."""
class DifyDriveLayerError(RuntimeError):
@ -34,53 +51,34 @@ class DifyDriveLayerError(RuntimeError):
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
shell: DifyShellLayer # pyright: ignore[reportUninitializedInstanceVariable]
@dataclass(slots=True)
class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntimeState]):
"""Drive runtime layer that eagerly materializes prompt-mentioned drive targets."""
"""Drive runtime layer that materializes prompt-mentioned targets via shell."""
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:
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,
)
return cls(config=DifyDriveLayerConfig.model_validate(config))
@property
@override
def prefix_prompts(self) -> list[str]:
return [self.build_prompt_context()]
@property
@override
def suffix_prompts(self) -> list[str]:
return [self.build_suffix_prompt()]
@override
async def on_context_create(self) -> None:
await self._pull_mentioned_targets()
@ -100,7 +98,11 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim
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}")
pulled_skill_path = self._pulled_file_paths.get(skill_key)
if pulled_skill_path is None:
continue
local_path = Path(pulled_skill_path).parent
loaded_skill_sections.append(f"Path: {skill.path}\nLocal path: {local_path}\nSKILL.md:\n{body}")
if loaded_skill_sections:
sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections))
@ -112,217 +114,138 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim
if mentioned_files:
sections.append("Mentioned files pulled to local drive:\n" + "\n".join(mentioned_files))
if not sections:
return ""
return "\n\n".join(sections)
def build_suffix_prompt(self) -> str:
sections: list[str] = []
mentioned_skill_keys = set(self.config.mentioned_skill_keys)
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 skill.skill_md_key not in 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."
)
pull_and_read_command = (
'`skill_dir="$(dify-agent drive pull <SKILL_PATH> --to /tmp/drive)"; '
+ 'printf "%s\\n" "$skill_dir"; cat "$skill_dir/SKILL.md"`'
)
sections.append(
"Other available skills:\n"
+ "\n".join(other_skills)
+ "\n\nTo use one, pull it and read its SKILL.md in one command: "
+ pull_and_read_command
+ "."
)
sections.append(_AGENT_STUB_CLI_USAGE_PROMPT)
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]
targets = self._mentioned_pull_targets()
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)
script = self._build_shell_pull_script(targets=targets)
result = await self.deps.shell.run_remote_script(script, inject_agent_stub_env=True)
if result.exit_code != 0:
raise DifyDriveLayerError(
f"drive mentioned pull failed in shell: {result.status} exit_code={result.exit_code}\n{result.output}"
)
if result.truncated:
raise DifyDriveLayerError("drive mentioned pull output was truncated before SKILL.md content was loaded")
written_paths, skill_bodies = self._parse_shell_pull_output(result.output)
self._record_pulled_paths(written_paths)
for skill_key in self.config.mentioned_skill_keys:
body = skill_bodies.get(skill_key)
if body is None:
raise DifyDriveLayerError(f"missing pulled SKILL.md content for mentioned skill {skill_key}")
self._loaded_skill_bodies[skill_key] = body
def _build_shell_pull_script(self, *, targets: list[tuple[str, bool]]) -> str:
pull_targets = list(dict.fromkeys(prefix for prefix, _exact in targets))
base_path = agent_stub_drive_base_for_ref(self.config.drive_ref)
lines = [
"set -eu",
f"base={shlex.quote(base_path)}",
"dify-agent drive pull " + " ".join(shlex.quote(target) for target in pull_targets) + ' --to "$base"',
]
for skill_key in self.config.mentioned_skill_keys:
skill_path = self._shell_local_path(skill_key)
lines.extend(
[
f"test -f {shlex.quote(skill_path)}",
f"printf '\\n__DIFY_DRIVE_MENTIONED_PATH__\\t%s\\t%s\\n' {shlex.quote(skill_key)} {shlex.quote(skill_path)}",
f"printf '__DIFY_DRIVE_SKILL_BEGIN__\\t%s\\n' {shlex.quote(skill_key)}",
f"cat {shlex.quote(skill_path)}",
f"printf '\\n__DIFY_DRIVE_SKILL_END__\\t%s\\n' {shlex.quote(skill_key)}",
]
)
for file_key in self.config.mentioned_file_keys:
file_path = self._shell_local_path(file_key)
lines.extend(
[
f"test -e {shlex.quote(file_path)}",
f"printf '\\n__DIFY_DRIVE_MENTIONED_PATH__\\t%s\\t%s\\n' {shlex.quote(file_key)} {shlex.quote(file_path)}",
]
)
return "\n".join(lines)
def _parse_shell_pull_output(self, output: str) -> tuple[dict[str, str], dict[str, str]]:
written_paths: dict[str, str] = {}
skill_bodies: dict[str, str] = {}
current_skill_key: str | None = None
current_skill_body: list[str] = []
for line in output.splitlines(keepends=True):
stripped_line = line.rstrip("\n")
if current_skill_key is not None:
if stripped_line == f"__DIFY_DRIVE_SKILL_END__\t{current_skill_key}":
skill_bodies[current_skill_key] = "".join(current_skill_body)
current_skill_key = None
current_skill_body = []
continue
current_skill_body.append(line)
continue
if stripped_line.startswith("__DIFY_DRIVE_MENTIONED_PATH__\t"):
parts = stripped_line.split("\t", 2)
if len(parts) != 3:
raise DifyDriveLayerError("drive mentioned pull emitted an invalid path marker")
_marker, key, path = parts
written_paths[key] = path
continue
if stripped_line.startswith("__DIFY_DRIVE_SKILL_BEGIN__\t"):
current_skill_key = stripped_line.split("\t", 1)[1]
current_skill_body = []
if current_skill_key is not None:
raise DifyDriveLayerError(f"drive mentioned pull omitted SKILL.md end marker for {current_skill_key}")
return written_paths, skill_bodies
def _record_pulled_paths(self, written_paths: dict[str, str]) -> None:
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:
if skill_key not in written_paths:
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)
def _mentioned_pull_targets(self) -> list[tuple[str, bool]]:
return [(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
]
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()
def _shell_local_path(self, drive_key: str) -> str:
return f"{agent_stub_drive_base_for_ref(self.config.drive_ref).rstrip('/')}/{drive_key.lstrip('/')}"
@staticmethod
def _skill_prefix(skill_key: str) -> str:
return f"{skill_key.rsplit('/', 1)[0]}/"
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"]

View File

@ -7,21 +7,31 @@ root stays import-safe for callers that only need to construct run requests.
from dify_agent.layers.knowledge.configs import (
DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID,
DifyKnowledgeBaseLayerConfig,
DifyKnowledgeDatasetConfig,
DifyKnowledgeEagerResult,
DifyKnowledgeMetadataCondition,
DifyKnowledgeMetadataConditions,
DifyKnowledgeMetadataFilteringConfig,
DifyKnowledgeModelConfig,
DifyKnowledgeQueryConfig,
DifyKnowledgeRerankingModelConfig,
DifyKnowledgeRetrievalConfig,
DifyKnowledgeRuntimeState,
DifyKnowledgeSetConfig,
)
__all__ = [
"DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID",
"DifyKnowledgeBaseLayerConfig",
"DifyKnowledgeDatasetConfig",
"DifyKnowledgeEagerResult",
"DifyKnowledgeMetadataCondition",
"DifyKnowledgeMetadataConditions",
"DifyKnowledgeMetadataFilteringConfig",
"DifyKnowledgeModelConfig",
"DifyKnowledgeQueryConfig",
"DifyKnowledgeRerankingModelConfig",
"DifyKnowledgeRetrievalConfig",
"DifyKnowledgeRuntimeState",
"DifyKnowledgeSetConfig",
]

View File

@ -1,12 +1,11 @@
"""Client-safe DTOs for the Dify knowledge-base Agenton layer.
The public layer config exposes only static retrieval controls: dataset ids,
retrieval strategy, metadata filtering, and observation-size limits. The agent
model itself should only ever see a single ``query`` tool argument; tenant/
app/user context comes from the execution-context layer and the actual
retrieval is delegated to the Dify API inner endpoint. Tool naming is not
caller-configurable: the runtime always exposes the same stable knowledge-base
search tool.
The public layer config carries one or more named knowledge sets. Each set owns
its dataset ids plus query, retrieval, and metadata-filtering policy. Generated-
query sets are exposed through one stable model-visible search tool whose
schema lets the model pick ``set_name`` and ``query``; user-query sets are
retrieved eagerly when the layer enters a run and their formatted observations
are kept only in JSON-safe ``runtime_state`` for session snapshots.
"""
from __future__ import annotations
@ -61,6 +60,44 @@ class DifyKnowledgeRerankingModelConfig(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class DifyKnowledgeDatasetConfig(BaseModel):
"""One dataset selected by a knowledge set.
Only ``id`` is used for retrieval. ``name`` and ``description`` are retained
because callers already have them and they are useful in runtime/debug
snapshots without changing the inner retrieval request contract.
"""
id: str
name: str | None = None
description: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@field_validator("id")
@classmethod
def validate_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("dataset id must not be blank")
return normalized
class DifyKnowledgeQueryConfig(BaseModel):
"""Query policy for one knowledge set."""
mode: Literal["user_query", "generated_query"]
value: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@model_validator(mode="after")
def validate_mode_specific_fields(self) -> DifyKnowledgeQueryConfig:
if self.mode == "user_query" and not (self.value or "").strip():
raise ValueError("query.value is required for user_query mode")
return self
class DifyKnowledgeRetrievalConfig(BaseModel):
"""Static retrieval controls mirrored into the inner API request."""
@ -151,38 +188,90 @@ class DifyKnowledgeMetadataFilteringConfig(BaseModel):
return payload
class DifyKnowledgeBaseLayerConfig(LayerConfig):
"""Public config for one model-visible knowledge search tool.
class DifyKnowledgeSetConfig(BaseModel):
"""One independently searchable or eagerly-preloaded knowledge set."""
The model only gets to choose whether to call the tool and what ``query``
to send. Dataset ids, retrieval settings, metadata filtering, and caller
context remain config/runtime concerns outside the model-visible tool
schema. The tool name and description are fixed by the layer runtime and do
not appear in the public config DTO.
"""
dataset_ids: list[str]
id: str
name: str
description: str | None = None
datasets: list[DifyKnowledgeDatasetConfig]
query: DifyKnowledgeQueryConfig
retrieval: DifyKnowledgeRetrievalConfig
metadata_filtering: DifyKnowledgeMetadataFilteringConfig = Field(
default_factory=DifyKnowledgeMetadataFilteringConfig
)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@field_validator("id", "name")
@classmethod
def validate_non_blank_identity(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("knowledge set id and name must not be blank")
return normalized
@model_validator(mode="after")
def validate_dataset_ids(self) -> DifyKnowledgeSetConfig:
if not self.datasets:
raise ValueError("knowledge set requires at least one dataset")
dataset_ids = [dataset.id for dataset in self.datasets]
if len(dataset_ids) != len(set(dataset_ids)):
raise ValueError("knowledge set dataset ids must be unique")
return self
@property
def dataset_ids(self) -> list[str]:
"""Return the selected dataset ids for the inner retrieval request."""
return [dataset.id for dataset in self.datasets]
class DifyKnowledgeEagerResult(BaseModel):
"""JSON-safe eager user-query result stored in layer runtime state."""
set_id: str
set_name: str
query: str
observation: str
status: Literal["success", "empty", "temporarily_unavailable"]
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class DifyKnowledgeRuntimeState(BaseModel):
"""Serializable eager-retrieval state stored in Agenton session snapshots."""
eager_config_fingerprint: str | None = None
eager_results: list[DifyKnowledgeEagerResult] = Field(default_factory=list)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True)
class DifyKnowledgeBaseLayerConfig(LayerConfig):
"""Public config for one knowledge-base layer.
The model-visible surface stays fixed to ``knowledge_base_search``. Set
names are the only model-visible selection labels; dataset ids, retrieval
controls, metadata filtering, and caller identity remain config/runtime
concerns outside the tool schema.
"""
sets: list[DifyKnowledgeSetConfig]
max_result_content_chars: int = Field(default=2000, ge=1)
max_observation_chars: int = Field(default=12000, ge=1)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@field_validator("dataset_ids")
@classmethod
def validate_dataset_ids(cls, value: list[str]) -> list[str]:
if not value:
raise ValueError("dataset_ids must contain at least one item")
normalized_ids = [item.strip() for item in value]
if any(not item for item in normalized_ids):
raise ValueError("dataset_ids must not contain blank items")
return normalized_ids
@model_validator(mode="after")
def validate_observation_limits(self) -> DifyKnowledgeBaseLayerConfig:
def validate_sets_and_observation_limits(self) -> DifyKnowledgeBaseLayerConfig:
if not self.sets:
raise ValueError("sets must contain at least one knowledge set")
set_ids = [knowledge_set.id for knowledge_set in self.sets]
if len(set_ids) != len(set(set_ids)):
raise ValueError("knowledge set ids must be unique")
normalized_names = [knowledge_set.name.strip().lower() for knowledge_set in self.sets]
if len(normalized_names) != len(set(normalized_names)):
raise ValueError("knowledge set names must be unique")
if self.max_observation_chars < self.max_result_content_chars:
raise ValueError("max_observation_chars must be greater than or equal to max_result_content_chars")
return self
@ -191,10 +280,15 @@ class DifyKnowledgeBaseLayerConfig(LayerConfig):
__all__ = [
"DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID",
"DifyKnowledgeBaseLayerConfig",
"DifyKnowledgeDatasetConfig",
"DifyKnowledgeEagerResult",
"DifyKnowledgeMetadataCondition",
"DifyKnowledgeMetadataConditions",
"DifyKnowledgeMetadataFilteringConfig",
"DifyKnowledgeModelConfig",
"DifyKnowledgeQueryConfig",
"DifyKnowledgeRerankingModelConfig",
"DifyKnowledgeRetrievalConfig",
"DifyKnowledgeRuntimeState",
"DifyKnowledgeSetConfig",
]

View File

@ -1,17 +1,18 @@
"""Dify knowledge-base layer exposing one model-visible search tool.
"""Dify knowledge-base layer exposing set-aware retrieval.
The layer depends on ``DifyExecutionContextLayer`` for tenant/app/user/invoke
identity, keeps retrieval controls in config only, and borrows a lifespan-owned
HTTP client for each tool invocation. It never owns live clients or stores
retrieved source content in layer state. Tool identity is intentionally fixed at
runtime: callers cannot rename the knowledge tool or override its description
through public layer config because the model-visible surface must stay stable
across API-side Agent Soul mappings.
identity. Generated-query sets become one stable model-visible
``knowledge_base_search(set_name, query)`` tool, while user-query sets are
retrieved eagerly during context entry and exposed as additional user prompt
content. Eager observations are persisted only as JSON-safe runtime state so
Agenton session snapshots can resume without repeating unchanged retrievals.
"""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import json
import logging
from typing import ClassVar, cast
@ -27,7 +28,13 @@ from dify_agent.layers.knowledge.client import (
DifyKnowledgeBaseClientError,
DifyKnowledgeRetrieveResponse,
)
from dify_agent.layers.knowledge.configs import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
from dify_agent.layers.knowledge.configs import (
DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID,
DifyKnowledgeBaseLayerConfig,
DifyKnowledgeEagerResult,
DifyKnowledgeRuntimeState,
DifyKnowledgeSetConfig,
)
logger = logging.getLogger(__name__)
@ -35,23 +42,14 @@ logger = logging.getLogger(__name__)
# public DTO cannot grow a parallel naming contract that diverges from the
# runtime knowledge-search surface.
_KNOWLEDGE_BASE_TOOL_NAME = "knowledge_base_search"
_KNOWLEDGE_BASE_TOOL_DESCRIPTION = "Search configured knowledge bases for information relevant to the query."
_KNOWLEDGE_BASE_TOOL_DESCRIPTION = (
"Search a configured knowledge set. Pick one configured set_name and provide a focused search query."
)
BLANK_QUERY_OBSERVATION = "knowledge base search requires a non-empty query"
NO_RESULTS_OBSERVATION = "No relevant knowledge base results were found."
TEMPORARY_UNAVAILABLE_OBSERVATION = (
"Knowledge base search is temporarily unavailable. Please continue without it if possible."
)
QUERY_TOOL_SCHEMA = {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query for the configured knowledge bases.",
}
},
"required": ["query"],
"additionalProperties": False,
}
class DifyKnowledgeBaseDeps(LayerDeps):
@ -61,8 +59,10 @@ class DifyKnowledgeBaseDeps(LayerDeps):
@dataclass(slots=True)
class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBaseLayerConfig]):
"""Layer that resolves one config-scoped knowledge search tool."""
class DifyKnowledgeBaseLayer(
PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBaseLayerConfig, DifyKnowledgeRuntimeState]
):
"""Layer that resolves set-scoped knowledge tools and eager user prompts."""
type_id: ClassVar[str | None] = DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
@ -95,7 +95,7 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
)
async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
"""Build one Pydantic AI tool that exposes only ``query`` to the model.
"""Build the unified generated-query Pydantic AI tool, when needed.
Knowledge tools depend on execution-context identity that is optional for
other run types but mandatory here: ``tenant_id``, ``user_id``,
@ -103,11 +103,15 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
any HTTP request is attempted. Tool execution then follows a strict
observation policy:
- unknown ``set_name`` returns a local validation observation;
- blank ``query`` returns a local validation observation;
- retryable client failures (timeouts, connection failures, HTTP
``429``/``502``) become a temporary-unavailable observation;
- non-retryable client failures are raised so the run fails fast.
"""
generated_sets = self._generated_query_sets()
if not generated_sets:
return []
if http_client.is_closed:
raise RuntimeError("DifyKnowledgeBaseLayer.get_tools() requires an open shared HTTP client.")
@ -118,54 +122,28 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
api_key=self.inner_api_key,
http_client=http_client,
)
set_by_name = {knowledge_set.name: knowledge_set for knowledge_set in generated_sets}
async def knowledge_base_search(_ctx: RunContext[object], query: str) -> str:
async def knowledge_base_search(_ctx: RunContext[object], set_name: str, query: str) -> str:
knowledge_set = set_by_name.get(set_name)
if knowledge_set is None:
return f"unknown knowledge set: {set_name}"
normalized_query = query.strip()
if not normalized_query:
return BLANK_QUERY_OBSERVATION
try:
response = await client.retrieve(
tenant_id=caller["tenant_id"],
user_id=caller["user_id"],
app_id=caller["app_id"],
user_from=caller["user_from"],
invoke_from=caller["invoke_from"],
dataset_ids=list(self.config.dataset_ids),
query=normalized_query,
retrieval=self.config.retrieval,
metadata_filtering=self.config.metadata_filtering,
)
except DifyKnowledgeBaseClientError as exc:
if exc.retryable:
logger.warning(
"knowledge base search temporarily unavailable",
extra={
"tenant_id": caller["tenant_id"],
"app_id": caller["app_id"],
"invoke_from": caller["invoke_from"],
"error_code": exc.error_code,
"status_code": exc.status_code,
},
)
return TEMPORARY_UNAVAILABLE_OBSERVATION
logger.error(
"knowledge base search failed",
extra={
"tenant_id": caller["tenant_id"],
"app_id": caller["app_id"],
"invoke_from": caller["invoke_from"],
"error_code": exc.error_code,
"status_code": exc.status_code,
},
)
raise
return _format_observation(response, self.config)
return await self._retrieve_for_set(
client=client,
caller=caller,
knowledge_set=knowledge_set,
query=normalized_query,
retryable_observation=True,
)
async def prepare_tool_definition(_ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition:
return ToolDefinition(
name=tool_def.name,
description=tool_def.description,
parameters_json_schema=QUERY_TOOL_SCHEMA,
parameters_json_schema=_tool_schema(generated_sets),
strict=tool_def.strict,
sequential=tool_def.sequential,
metadata=tool_def.metadata,
@ -181,11 +159,177 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
knowledge_base_search,
takes_ctx=True,
name=_KNOWLEDGE_BASE_TOOL_NAME,
description=_KNOWLEDGE_BASE_TOOL_DESCRIPTION,
description=_tool_description(generated_sets),
prepare=prepare_tool_definition,
)
]
@property
@override
def user_prompts(self) -> list[str]:
"""Expose eager user-query results as an additional user prompt."""
if not self.runtime_state.eager_results:
return []
sections: list[str] = []
for result in self.runtime_state.eager_results:
sections.append(
"\n".join(
[
f"Set: {result.set_name}",
f"Query: {result.query}",
"Results:",
result.observation,
]
)
)
return ["Knowledge retrieval results:\n\n" + "\n\n".join(sections)]
@override
async def on_context_create(self) -> None:
await self._refresh_eager_results_if_needed()
@override
async def on_context_resume(self) -> None:
await self._refresh_eager_results_if_needed()
def _generated_query_sets(self) -> list[DifyKnowledgeSetConfig]:
return [knowledge_set for knowledge_set in self.config.sets if knowledge_set.query.mode == "generated_query"]
def _user_query_sets(self) -> list[DifyKnowledgeSetConfig]:
return [knowledge_set for knowledge_set in self.config.sets if knowledge_set.query.mode == "user_query"]
async def _refresh_eager_results_if_needed(self) -> None:
user_query_sets = self._user_query_sets()
if not user_query_sets:
self.runtime_state.eager_config_fingerprint = None
self.runtime_state.eager_results = []
return
fingerprint = _eager_config_fingerprint(user_query_sets)
if self.runtime_state.eager_config_fingerprint == fingerprint:
return
caller = _build_caller_context(self.deps.execution_context.config)
async with httpx.AsyncClient() as http_client:
client = DifyKnowledgeBaseClient(
base_url=self.inner_api_url,
api_key=self.inner_api_key,
http_client=http_client,
)
eager_results: list[DifyKnowledgeEagerResult] = []
for knowledge_set in user_query_sets:
query = (knowledge_set.query.value or "").strip()
try:
response = await client.retrieve(
tenant_id=caller["tenant_id"],
user_id=caller["user_id"],
app_id=caller["app_id"],
user_from=caller["user_from"],
invoke_from=caller["invoke_from"],
dataset_ids=knowledge_set.dataset_ids,
query=query,
retrieval=knowledge_set.retrieval,
metadata_filtering=knowledge_set.metadata_filtering,
)
except DifyKnowledgeBaseClientError as exc:
if exc.retryable:
logger.warning(
"eager knowledge retrieval temporarily unavailable",
extra={
"tenant_id": caller["tenant_id"],
"app_id": caller["app_id"],
"invoke_from": caller["invoke_from"],
"knowledge_set_id": knowledge_set.id,
"error_code": exc.error_code,
"status_code": exc.status_code,
},
)
eager_results.append(
DifyKnowledgeEagerResult(
set_id=knowledge_set.id,
set_name=knowledge_set.name,
query=query,
observation=TEMPORARY_UNAVAILABLE_OBSERVATION,
status="temporarily_unavailable",
)
)
continue
logger.error(
"eager knowledge retrieval failed",
extra={
"tenant_id": caller["tenant_id"],
"app_id": caller["app_id"],
"invoke_from": caller["invoke_from"],
"knowledge_set_id": knowledge_set.id,
"error_code": exc.error_code,
"status_code": exc.status_code,
},
)
raise
eager_results.append(
DifyKnowledgeEagerResult(
set_id=knowledge_set.id,
set_name=knowledge_set.name,
query=query,
observation=_format_observation(response, self.config, include_heading=False),
status="success" if response.results else "empty",
)
)
self.runtime_state.eager_results = eager_results
self.runtime_state.eager_config_fingerprint = fingerprint
async def _retrieve_for_set(
self,
*,
client: DifyKnowledgeBaseClient,
caller: dict[str, str],
knowledge_set: DifyKnowledgeSetConfig,
query: str,
retryable_observation: bool,
) -> str:
try:
response = await client.retrieve(
tenant_id=caller["tenant_id"],
user_id=caller["user_id"],
app_id=caller["app_id"],
user_from=caller["user_from"],
invoke_from=caller["invoke_from"],
dataset_ids=knowledge_set.dataset_ids,
query=query,
retrieval=knowledge_set.retrieval,
metadata_filtering=knowledge_set.metadata_filtering,
)
except DifyKnowledgeBaseClientError as exc:
if exc.retryable and retryable_observation:
logger.warning(
"knowledge base search temporarily unavailable",
extra={
"tenant_id": caller["tenant_id"],
"app_id": caller["app_id"],
"invoke_from": caller["invoke_from"],
"knowledge_set_id": knowledge_set.id,
"error_code": exc.error_code,
"status_code": exc.status_code,
},
)
return TEMPORARY_UNAVAILABLE_OBSERVATION
logger.error(
"knowledge base search failed",
extra={
"tenant_id": caller["tenant_id"],
"app_id": caller["app_id"],
"invoke_from": caller["invoke_from"],
"knowledge_set_id": knowledge_set.id,
"error_code": exc.error_code,
"status_code": exc.status_code,
},
)
raise
return _format_observation(response, self.config)
def _build_caller_context(execution_context: object) -> dict[str, str]:
"""Extract the inner-API caller identity from execution-context config.
@ -232,7 +376,56 @@ def _build_caller_context(execution_context: object) -> dict[str, str]:
}
def _format_observation(response: DifyKnowledgeRetrieveResponse, config: DifyKnowledgeBaseLayerConfig) -> str:
def _tool_schema(generated_sets: list[DifyKnowledgeSetConfig]) -> dict[str, object]:
return {
"type": "object",
"properties": {
"set_name": {
"type": "string",
"enum": [knowledge_set.name for knowledge_set in generated_sets],
"description": "Knowledge set to search.",
},
"query": {
"type": "string",
"description": "Search query for the selected knowledge set.",
},
},
"required": ["set_name", "query"],
"additionalProperties": False,
}
def _tool_description(generated_sets: list[DifyKnowledgeSetConfig]) -> str:
set_descriptions = []
for knowledge_set in generated_sets:
if knowledge_set.description:
set_descriptions.append(f"{knowledge_set.name}: {knowledge_set.description}")
else:
set_descriptions.append(knowledge_set.name)
return f"{_KNOWLEDGE_BASE_TOOL_DESCRIPTION} Configured sets: {', '.join(set_descriptions)}."
def _eager_config_fingerprint(user_query_sets: list[DifyKnowledgeSetConfig]) -> str:
payload = [
{
"id": knowledge_set.id,
"query": knowledge_set.query.model_dump(mode="json"),
"dataset_ids": knowledge_set.dataset_ids,
"retrieval": knowledge_set.retrieval.model_dump(mode="json"),
"metadata_filtering": knowledge_set.metadata_filtering.model_dump(mode="json", by_alias=True),
}
for knowledge_set in user_query_sets
]
serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def _format_observation(
response: DifyKnowledgeRetrieveResponse,
config: DifyKnowledgeBaseLayerConfig,
*,
include_heading: bool = True,
) -> str:
"""Render inner-API retrieval results into the model-visible tool response.
The formatting contract is intentionally simple and stable for the model:
@ -248,7 +441,7 @@ def _format_observation(response: DifyKnowledgeRetrieveResponse, config: DifyKno
if not response.results:
return NO_RESULTS_OBSERVATION
lines = ["Knowledge base search results:"]
lines = ["Knowledge base search results:"] if include_heading else []
for index, result in enumerate(response.results, start=1):
metadata = result.metadata
title = result.title or metadata.document_name or "Untitled"
@ -280,6 +473,5 @@ __all__ = [
"DifyKnowledgeBaseDeps",
"DifyKnowledgeBaseLayer",
"NO_RESULTS_OBSERVATION",
"QUERY_TOOL_SCHEMA",
"TEMPORARY_UNAVAILABLE_OBSERVATION",
]

View File

@ -3,7 +3,8 @@
Server-only shellctl connection settings are injected by the runtime provider
factory. Public config carries product-level Agent Soul settings that must affect
the sandbox workspace itself: CLI tool bootstrap commands, normal environment
variables, secret environment variable names, and sandbox-provider metadata.
variables, secret environment variable names, sandbox-provider metadata, and the
Agent Stub drive ref used by shell-visible drive commands.
"""
import re
@ -80,6 +81,8 @@ class DifyShellLayerConfig(LayerConfig):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
# Optional because shell can be used without a drive layer.
agent_stub_drive_ref: str | None = Field(default=None, max_length=1024)
cli_tools: list[DifyShellCliToolConfig] = Field(default_factory=list)
env: list[DifyShellEnvVarConfig] = Field(default_factory=list)
secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list)

View File

@ -51,7 +51,6 @@ 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
@ -173,11 +172,11 @@ type ShellInterruptToolResult = ShellJobStatusObservation | ShellToolErrorObserv
class DifyShellLayerDeps(LayerDeps):
"""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.
The execution context supplies the token principal. The drive ref used for
Agent Stub CLI commands is passed through config so the drive layer can
depend on shell for eager materialization without a dependency cycle.
"""
drive: DifyDriveLayer | None # pyright: ignore[reportUninitializedInstanceVariable]
execution_context: DifyExecutionContextLayer | None # pyright: ignore[reportUninitializedInstanceVariable]
@ -768,10 +767,9 @@ 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_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,
agent_stub_drive_ref=self.config.agent_stub_drive_ref,
execution_context=execution_context,
token_factory=self.agent_stub_token_factory,
session_id=self.runtime_state.session_id,

View File

@ -38,7 +38,6 @@ 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
@ -90,14 +89,7 @@ def create_default_layer_providers(
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
LayerProvider.from_layer_type(DifyOutputLayer),
LayerProvider.from_layer_type(DifyAskHumanLayer),
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_layer_type(DifyDriveLayer),
LayerProvider.from_factory(
layer_type=DifyExecutionContextLayer,
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(

View File

@ -10,7 +10,9 @@ 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_API_BASE_URL`` uses ``grpc://``.
server when ``DIFY_AGENT_STUB_API_BASE_URL`` uses ``grpc://``. Process-level
Logfire instrumentation is configured at app construction time and only exports
remotely when Logfire's default environment configuration provides a token.
"""
from collections.abc import AsyncGenerator
@ -25,6 +27,7 @@ from dify_agent.agent_stub.server.grpc_runtime import start_agent_stub_grpc_serv
from dify_agent.agent_stub.server.router import create_agent_stub_router
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.run_scheduler import RunScheduler
from dify_agent.server.observability import configure_server_observability
from dify_agent.server.routes.runs import create_runs_router
from dify_agent.server.routes.sandbox_files import create_sandbox_files_router
from dify_agent.server.sandbox_files import SandboxFileService
@ -94,6 +97,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
await redis.aclose()
app = FastAPI(title="Dify Agent Run Server", version="0.1.0", lifespan=lifespan)
configure_server_observability(app)
def get_store() -> RedisRunStore:
return state["store"] # pyright: ignore[reportReturnType]

View File

@ -0,0 +1,47 @@
"""Process-level Logfire setup for the Dify Agent run server.
The run server performs observability setup at the FastAPI app boundary rather
than inside agent runtime code. Global instrumentations cover shared HTTPX,
Redis, and Pydantic AI clients once per process; the FastAPI instrumentation is
applied per app instance because tests and embedded callers can build multiple
apps in one Python process. Remote export remains token-gated through Logfire's
``if-token-present`` mode and Logfire's default environment-variable handling,
so development without a token only writes Logfire's console output locally.
"""
from __future__ import annotations
import logfire
from fastapi import FastAPI
_global_instrumentation_ready = False
def configure_server_observability(app: FastAPI) -> None:
"""Configure Logfire and instrument the server's framework/client boundaries.
Instrumentation calls intentionally use Logfire's defaults instead of
re-exposing capture options through Dify settings. The only Dify-owned
policy here is that remote export stays token-gated while local console
output still works without a token.
"""
global _global_instrumentation_ready
logfire.configure(
send_to_logfire="if-token-present",
inspect_arguments=False,
)
if not _global_instrumentation_ready:
logfire.instrument_httpx()
logfire.instrument_redis()
logfire.instrument_pydantic_ai()
_global_instrumentation_ready = True
if getattr(app.state, "dify_agent_logfire_instrumented", False):
return
logfire.instrument_fastapi(app)
app.state.dify_agent_logfire_instrumented = True
__all__ = ["configure_server_observability"]

View File

@ -8,7 +8,9 @@ from zipfile import ZipFile, ZipInfo
import pytest
from dify_agent.agent_stub.cli._drive import (
list_drive_from_environment,
DrivePullResult,
format_drive_manifest,
list_drive_manifest_from_environment,
pull_drive_from_environment,
push_drive_from_environment,
)
@ -22,7 +24,7 @@ from dify_agent.agent_stub.protocol.agent_stub import (
)
def test_list_drive_from_environment_returns_manifest_json_model(monkeypatch: pytest.MonkeyPatch) -> None:
def test_list_drive_manifest_from_environment_returns_manifest_model(monkeypatch: pytest.MonkeyPatch) -> 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: dict[str, object] = {}
@ -47,7 +49,7 @@ def test_list_drive_from_environment_returns_manifest_json_model(monkeypatch: py
fake_manifest,
)
result = list_drive_from_environment(prefix="skills/", json_output=True)
result = list_drive_manifest_from_environment(prefix="skills/")
assert isinstance(result, AgentStubDriveManifestResponse)
assert result.items[0].key == "skills/example/SKILL.md"
@ -55,7 +57,7 @@ def test_list_drive_from_environment_returns_manifest_json_model(monkeypatch: py
assert captured["include_download_url"] is False
def test_list_drive_from_environment_returns_human_readable_listing(monkeypatch: pytest.MonkeyPatch) -> None:
def test_format_drive_manifest_returns_human_readable_listing(monkeypatch: pytest.MonkeyPatch) -> 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: dict[str, object] = {}
@ -88,7 +90,7 @@ def test_list_drive_from_environment_returns_human_readable_listing(monkeypatch:
fake_manifest,
)
result = list_drive_from_environment(prefix="skills/", json_output=False)
result = format_drive_manifest(list_drive_manifest_from_environment(prefix="skills/"))
assert result == ("12\ttext/markdown\t-\tskills/example/SKILL.md\n-\t-\tsha256:abc\tskills/example/helper.py")
assert captured["prefix"] == "skills/"
@ -128,10 +130,10 @@ def test_pull_drive_from_environment_writes_files_under_drive_base(
lambda **_kwargs: b"hello world",
)
results = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path))
result = pull_drive_from_environment(targets=["skills/"], local_base=str(tmp_path))
assert results == [tmp_path / "skills" / "example" / "SKILL.md"]
assert results[0].read_bytes() == b"hello world"
assert result.model_dump() == {"items": [{"key": "skills/", "local_path": str(tmp_path / "skills")}]}
assert (tmp_path / "skills" / "example" / "SKILL.md").read_bytes() == b"hello world"
assert captured["prefix"] == "skills/"
assert captured["include_download_url"] is True
@ -169,11 +171,11 @@ def test_pull_drive_from_environment_auto_extracts_skill_archive(
lambda **_kwargs: archive_bytes,
)
results = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
result = pull_drive_from_environment(targets=["skills/foo"], local_base=str(tmp_path))
archive_path = tmp_path / "skills" / "foo" / ".DIFY-SKILL-FULL.zip"
assert results == [archive_path]
assert archive_path.read_bytes() == archive_bytes
assert result.model_dump() == {"items": [{"key": "skills/foo", "local_path": str(tmp_path / "skills" / "foo")}]}
assert not archive_path.exists()
assert (tmp_path / "skills" / "foo" / "SKILL.md").read_text(encoding="utf-8") == "# Example\n"
assert (tmp_path / "skills" / "foo" / "nested" / "helper.py").read_text(encoding="utf-8") == "print('x')\n"
@ -202,7 +204,7 @@ def test_pull_drive_from_environment_rejects_traversal_keys(
)
with pytest.raises(AgentStubValidationError, match="outside the drive base"):
_ = pull_drive_from_environment(targets=[""], drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=[""], local_base=str(tmp_path))
def test_pull_drive_from_environment_rejects_skill_archive_path_traversal(
@ -239,7 +241,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_path_traversal(
)
with pytest.raises(AgentStubValidationError, match="path traversal"):
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/foo"], local_base=str(tmp_path))
assert not (tmp_path / "skills" / "foo" / "SKILL.md").exists()
@ -276,7 +278,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_absolute_entry(
)
with pytest.raises(AgentStubValidationError, match="absolute path"):
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/foo"], local_base=str(tmp_path))
def test_pull_drive_from_environment_rejects_skill_archive_symlink_entry(
@ -314,7 +316,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_symlink_entry(
)
with pytest.raises(AgentStubValidationError, match="symlink entry"):
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/foo"], local_base=str(tmp_path))
def test_pull_drive_from_environment_rejects_invalid_skill_archive(
@ -347,7 +349,7 @@ def test_pull_drive_from_environment_rejects_invalid_skill_archive(
)
with pytest.raises(AgentStubTransferError, match="downloaded skill archive is invalid"):
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/foo"], local_base=str(tmp_path))
def test_pull_drive_from_environment_rejects_missing_download_url(
@ -373,7 +375,7 @@ def test_pull_drive_from_environment_rejects_missing_download_url(
)
with pytest.raises(AgentStubValidationError, match="missing download_url"):
_ = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/"], local_base=str(tmp_path))
def test_pull_drive_from_environment_rejects_size_mismatch(
@ -404,7 +406,7 @@ def test_pull_drive_from_environment_rejects_size_mismatch(
)
with pytest.raises(AgentStubTransferError, match="size mismatch"):
_ = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/"], local_base=str(tmp_path))
def test_pull_drive_from_environment_requests_multiple_targets_and_deduplicates_overlaps(
@ -463,11 +465,16 @@ def test_pull_drive_from_environment_requests_multiple_targets_and_deduplicates_
),
)
results = pull_drive_from_environment(targets=["skills/foo", "files/a.txt"], drive_base=str(tmp_path))
result = pull_drive_from_environment(targets=["skills/foo", "files/a.txt"], local_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"]
assert set(captured_prefixes) == {"skills/foo", "files/a.txt"}
assert len(captured_prefixes) == 2
assert {(item.key, item.local_path) for item in result.items} == {
("files/a.txt", str(tmp_path / "files" / "a.txt")),
("skills/foo", str(tmp_path / "skills" / "foo")),
}
assert set(downloaded_urls) == {"https://files.example.com/a-txt", "https://files.example.com/skill-md"}
assert len(downloaded_urls) == 2
def test_pull_drive_from_environment_without_targets_preserves_whole_drive_pull(
@ -482,10 +489,44 @@ def test_pull_drive_from_environment_without_targets_preserves_whole_drive_pull(
lambda **kwargs: captured_prefixes.append(kwargs["prefix"]) or AgentStubDriveManifestResponse(items=[]),
)
assert pull_drive_from_environment(drive_base=str(tmp_path)) == []
assert pull_drive_from_environment(local_base=str(tmp_path)).model_dump() == {"items": []}
assert captured_prefixes == [""]
def test_pull_drive_from_environment_returns_json_result(
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")
monkeypatch.setattr(
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
lambda **_kwargs: AgentStubDriveManifestResponse(
items=[
AgentStubDriveItem(
key="files/a.txt",
size=1,
hash=None,
mime_type="text/plain",
file_kind="tool_file",
file_id="tool-file-1",
download_url="https://files.example.com/a-txt",
)
]
),
)
monkeypatch.setattr(
"dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync",
lambda **_kwargs: b"a",
)
result = pull_drive_from_environment(targets=["files/a.txt"], local_base=str(tmp_path))
assert isinstance(result, DrivePullResult)
assert result.model_dump() == {"items": [{"key": "files/a.txt", "local_path": str(tmp_path / "files" / "a.txt")}]}
assert (tmp_path / "files" / "a.txt").read_bytes() == b"a"
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")
@ -518,21 +559,46 @@ def test_push_drive_from_environment_commits_single_file(monkeypatch: pytest.Mon
monkeypatch.setattr("dify_agent.agent_stub.cli._drive.request_agent_stub_drive_commit_sync", fake_commit)
response = push_drive_from_environment(local_path=str(source), drive_path="files/report.pdf", recursive=False)
response = push_drive_from_environment(local_path=str(source), drive_path="files/report.pdf", kind=None)
assert response.items[0].key == "files/report.pdf"
request = captured["request"]
assert isinstance(request, AgentStubDriveCommitRequest)
assert request.items[0].model_dump(mode="json") == {
"key": "files/report.pdf",
"file_ref": {"kind": "tool_file", "id": "tool-file-1"},
"value_owned_by_drive": True,
"is_skill": False,
"skill_metadata": None,
}
assert request.items[0].key == "files/report.pdf"
assert request.items[0].file_ref is not None
assert request.items[0].file_ref.kind == "tool_file"
assert request.items[0].file_ref.id == "tool-file-1"
def test_push_drive_from_environment_requires_skill_md_for_non_recursive_directory(
def test_push_drive_from_environment_rejects_file_with_kind_skill(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source = tmp_path / "report.pdf"
source.write_bytes(b"report")
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="--kind skill requires a directory containing SKILL.md"):
_ = push_drive_from_environment(local_path=str(source), drive_path="files/report.pdf", kind="skill")
def test_push_drive_from_environment_rejects_symlinked_file_root(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source = tmp_path / "report.pdf"
source.write_bytes(b"report")
symlink_path = tmp_path / "report-link.pdf"
symlink_path.symlink_to(source)
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"):
_ = push_drive_from_environment(local_path=str(symlink_path), drive_path="files/report.pdf", kind=None)
def test_push_drive_from_environment_requires_kind_for_directory(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@ -541,11 +607,24 @@ def test_push_drive_from_environment_requires_skill_md_for_non_recursive_directo
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"):
_ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False)
with pytest.raises(AgentStubValidationError, match="requires --kind skill or --kind dir"):
_ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind=None)
def test_push_drive_from_environment_standardizes_non_recursive_skill_directory(
def test_push_drive_from_environment_kind_skill_requires_skill_md(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
skill_dir = tmp_path / "skill"
skill_dir.mkdir()
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="requires a directory containing SKILL.md"):
_ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind="skill")
def test_push_drive_from_environment_kind_skill_standardizes_skill_directory(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@ -584,7 +663,7 @@ def test_push_drive_from_environment_standardizes_non_recursive_skill_directory(
),
)
response = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False)
response = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind="skill")
assert set(uploaded_paths) == {"SKILL.md", ".DIFY-SKILL-FULL.zip"}
assert {item.key for item in response.items} == {
@ -593,7 +672,7 @@ def test_push_drive_from_environment_standardizes_non_recursive_skill_directory(
}
def test_push_drive_from_environment_non_recursive_archive_excludes_transient_entries(
def test_push_drive_from_environment_kind_skill_archive_excludes_transient_entries(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@ -641,7 +720,7 @@ def test_push_drive_from_environment_non_recursive_archive_excludes_transient_en
),
)
_ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False)
_ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind="skill")
assert {"SKILL.md", "helper.py"}.issubset(archive_entries)
assert ".git/config" not in archive_entries
@ -649,7 +728,7 @@ def test_push_drive_from_environment_non_recursive_archive_excludes_transient_en
assert ".DIFY-SKILL-FULL.zip" not in archive_entries
def test_push_drive_from_environment_non_recursive_rejects_symlinked_archive_entries(
def test_push_drive_from_environment_kind_skill_rejects_symlinked_archive_entries(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@ -663,10 +742,52 @@ def test_push_drive_from_environment_non_recursive_rejects_symlinked_archive_ent
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
with pytest.raises(AgentStubValidationError, match="symlink"):
_ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False)
_ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind="skill")
def test_push_drive_from_environment_rejects_symlinked_recursive_files(
def test_push_drive_from_environment_kind_dir_requires_directory(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source = tmp_path / "report.pdf"
source.write_bytes(b"report")
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="--kind dir requires a directory"):
_ = push_drive_from_environment(local_path=str(source), drive_path="files/report.pdf", kind="dir")
def test_push_drive_from_environment_kind_file_requires_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
skill_dir = tmp_path / "skill"
skill_dir.mkdir()
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="--kind file requires a file"):
_ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind="file")
def test_push_drive_from_environment_rejects_symlinked_directory_root(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source_dir = tmp_path / "skill"
source_dir.mkdir()
(source_dir / "SKILL.md").write_text("# Example\n", encoding="utf-8")
symlink_path = tmp_path / "skill-link"
symlink_path.symlink_to(source_dir, target_is_directory=True)
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"):
_ = push_drive_from_environment(local_path=str(symlink_path), drive_path="skills/example", kind="skill")
def test_push_drive_from_environment_kind_dir_rejects_symlinked_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@ -679,10 +800,10 @@ def test_push_drive_from_environment_rejects_symlinked_recursive_files(
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
with pytest.raises(AgentStubValidationError, match="symlink"):
_ = push_drive_from_environment(local_path=str(root), drive_path="skills/example", recursive=True)
_ = push_drive_from_environment(local_path=str(root), drive_path="skills/example", kind="dir")
def test_push_drive_from_environment_recursive_keeps_user_files_that_skill_packaging_skips(
def test_push_drive_from_environment_kind_dir_keeps_user_files_that_skill_packaging_skips(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@ -723,7 +844,7 @@ def test_push_drive_from_environment_recursive_keeps_user_files_that_skill_packa
),
)
response = push_drive_from_environment(local_path=str(root), drive_path="skills/example", recursive=True)
response = push_drive_from_environment(local_path=str(root), drive_path="skills/example", kind="dir")
assert set(uploaded_paths) == {".DIFY-SKILL-FULL.zip", "node_modules/module.js"}
assert {item.key for item in response.items} == {

View File

@ -11,7 +11,7 @@ from dify_agent.agent_stub.cli._files import (
upload_file_from_environment,
upload_tool_file_resource_from_environment,
)
from dify_agent.agent_stub.client._errors import AgentStubTransferError
from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError
def _reference(record_id: str) -> str:
@ -120,7 +120,7 @@ def test_download_file_from_environment_saves_bytes_and_renames_on_collision(
result = download_file_from_environment(
transfer_method="tool_file",
reference_or_url=_reference("tool-file-1"),
directory=str(target_dir),
local_dir=str(target_dir),
)
assert result.path.name == "report (1).pdf"
@ -157,7 +157,7 @@ def test_download_file_from_environment_sanitizes_server_filename(
result = download_file_from_environment(
transfer_method="tool_file",
reference_or_url=_reference("tool-file-1"),
directory=str(target_dir),
local_dir=str(target_dir),
)
assert result.path.parent == target_dir
@ -187,6 +187,62 @@ def test_upload_file_from_environment_rejects_non_canonical_reference(
_ = upload_file_from_environment(path=str(source))
def test_download_file_from_environment_supports_mapping_json(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target_dir = tmp_path / "inputs"
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] = {}
def fake_request_download(**kwargs):
captured["file"] = kwargs["file"]
return type(
"Response",
(),
{
"filename": "report.pdf",
"mime_type": "application/pdf",
"size": 12,
"download_url": "https://files.example.com/download",
},
)()
monkeypatch.setattr("dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync", fake_request_download)
monkeypatch.setattr(
"dify_agent.agent_stub.cli._files.download_file_bytes_from_signed_url_sync",
lambda **_kwargs: b"downloaded",
)
result = download_file_from_environment(
mapping=json.dumps({"transfer_method": "tool_file", "reference": _reference("tool-file-1")}),
local_dir=str(target_dir),
)
assert captured["file"].model_dump() == {
"transfer_method": "tool_file",
"reference": _reference("tool-file-1"),
"url": None,
}
assert result.path == target_dir / "report.pdf"
assert result.path.read_bytes() == b"downloaded"
def test_download_file_from_environment_requires_mapping_or_positional_pair() -> None:
with pytest.raises(AgentStubValidationError, match="requires either --mapping or TRANSFER_METHOD REFERENCE_OR_URL"):
_ = download_file_from_environment()
def test_download_file_from_environment_rejects_mapping_mixed_with_positionals() -> None:
with pytest.raises(AgentStubValidationError, match="cannot be combined"):
_ = download_file_from_environment(
transfer_method="tool_file",
reference_or_url=_reference("tool-file-1"),
mapping=json.dumps({"transfer_method": "tool_file", "reference": _reference("tool-file-1")}),
)
def test_upload_tool_file_resource_from_environment_rejects_missing_id(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,

View File

@ -6,6 +6,7 @@ from pathlib import Path
import pytest
from dify_agent.agent_stub.cli._drive import DrivePullResult
from dify_agent.agent_stub.cli.main import main
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubDriveCommitResponse,
@ -194,20 +195,81 @@ def test_cli_file_download_prints_saved_path(
)
with pytest.raises(SystemExit) as exc_info:
main(["file", "download", "tool_file", _reference("tool-file-1"), "/tmp"])
main(["file", "download", "tool_file", _reference("tool-file-1"), "--to", "/tmp"])
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert captured.out.strip() == "/tmp/report.pdf"
def test_cli_file_download_supports_mapping_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
captured_kwargs: dict[str, object] = {}
def fake_download_file_from_environment(**kwargs):
captured_kwargs.update(kwargs)
return type("Response", (), {"path": Path("/tmp/inputs/report.pdf")})()
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.download_file_from_environment", fake_download_file_from_environment
)
with pytest.raises(SystemExit) as exc_info:
main(
[
"file",
"download",
"--mapping",
json.dumps({"transfer_method": "tool_file", "reference": _reference("tool-file-1")}),
"--to",
"/tmp/inputs",
]
)
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert captured_kwargs == {
"transfer_method": None,
"reference_or_url": None,
"mapping": json.dumps({"transfer_method": "tool_file", "reference": _reference("tool-file-1")}),
"local_dir": "/tmp/inputs",
}
assert captured.out.strip() == "/tmp/inputs/report.pdf"
def test_cli_file_download_rejects_legacy_positional_directory(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
called = False
def fake_download_file_from_environment(**_kwargs):
nonlocal called
called = True
return type("Response", (), {"path": Path("/tmp/report.pdf")})()
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.download_file_from_environment", fake_download_file_from_environment
)
with pytest.raises(SystemExit) as exc_info:
main(["file", "download", "tool_file", _reference("tool-file-1"), "/tmp"])
captured = capsys.readouterr()
assert exc_info.value.code == 2
assert called is False
assert "/tmp" in captured.err
def test_cli_drive_list_prints_manifest_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.list_drive_from_environment",
lambda *, prefix, json_output: AgentStubDriveManifestResponse(
"dify_agent.agent_stub.cli.main.list_drive_manifest_from_environment",
lambda *, prefix: AgentStubDriveManifestResponse(
items=[
AgentStubDriveItem(
key=prefix + "example/SKILL.md",
@ -234,8 +296,19 @@ def test_cli_drive_list_prints_human_readable_listing(
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.list_drive_from_environment",
lambda *, prefix, json_output: f"12\ttext/markdown\t-\t{prefix}example/SKILL.md",
"dify_agent.agent_stub.cli.main.list_drive_manifest_from_environment",
lambda *, prefix: AgentStubDriveManifestResponse(
items=[
AgentStubDriveItem(
key=f"{prefix}example/SKILL.md",
size=12,
hash=None,
mime_type="text/markdown",
file_kind="tool_file",
file_id="tool-file-1",
)
]
),
)
with pytest.raises(SystemExit) as exc_info:
@ -252,14 +325,20 @@ def test_cli_drive_pull_prints_downloaded_paths(
) -> None:
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
lambda *, targets, drive_base: [
Path(drive_base) / targets[0] / "SKILL.md",
Path(drive_base) / targets[0] / "helper.py",
],
lambda *, targets, local_base: DrivePullResult(
items=[
DrivePullResult.Item(
key=f"{targets[0]}/SKILL.md", local_path=str(Path(local_base) / targets[0] / "SKILL.md")
),
DrivePullResult.Item(
key=f"{targets[0]}/helper.py", local_path=str(Path(local_base) / targets[0] / "helper.py")
),
]
),
)
with pytest.raises(SystemExit) as exc_info:
main(["drive", "pull", "skills/example", "--drive-base", "/tmp/drive"])
main(["drive", "pull", "skills/example", "--to", "/tmp/drive"])
captured = capsys.readouterr()
assert exc_info.value.code == 0
@ -269,16 +348,49 @@ def test_cli_drive_pull_prints_downloaded_paths(
]
def test_cli_drive_pull_prints_json_result(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
lambda *, targets, local_base: DrivePullResult(
items=[
DrivePullResult.Item(key="files/a.txt", local_path=f"{local_base}/files/a.txt"),
DrivePullResult.Item(key="skills/foo/SKILL.md", local_path=f"{local_base}/skills/foo/SKILL.md"),
]
),
)
with pytest.raises(SystemExit) as exc_info:
main(["drive", "pull", "files/a.txt", "--to", "/tmp/drive", "--json"])
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert json.loads(captured.out) == {
"items": [
{"key": "files/a.txt", "local_path": "/tmp/drive/files/a.txt"},
{"key": "skills/foo/SKILL.md", "local_path": "/tmp/drive/skills/foo/SKILL.md"},
]
}
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):
def fake_pull_drive_from_environment(*, targets, local_base):
captured_kwargs["targets"] = targets
captured_kwargs["drive_base"] = drive_base
return [Path(drive_base) / "skills" / "foo" / "SKILL.md"]
captured_kwargs["local_base"] = local_base
return DrivePullResult(
items=[
DrivePullResult.Item(
key="skills/foo/SKILL.md", local_path=str(Path(local_base) / "skills" / "foo" / "SKILL.md")
)
]
)
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
@ -286,11 +398,11 @@ def test_cli_drive_pull_forwards_multiple_targets(
)
with pytest.raises(SystemExit) as exc_info:
main(["drive", "pull", "skills/foo", "files/a.txt", "--drive-base", "/tmp/drive"])
main(["drive", "pull", "skills/foo", "files/a.txt", "--to", "/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_kwargs == {"targets": ["skills/foo", "files/a.txt"], "local_base": "/tmp/drive"}
assert captured.out.strip() == "/tmp/drive/skills/foo/SKILL.md"
@ -301,10 +413,16 @@ def test_cli_drive_pull_uses_environment_drive_base_default(
monkeypatch.setenv("DIFY_AGENT_STUB_DRIVE_BASE", "/env/drive")
captured_kwargs: dict[str, object] = {}
def fake_pull_drive_from_environment(*, targets, drive_base):
def fake_pull_drive_from_environment(*, targets, local_base):
captured_kwargs["targets"] = targets
captured_kwargs["drive_base"] = drive_base
return [Path(drive_base) / "skills" / "foo" / "SKILL.md"]
captured_kwargs["local_base"] = local_base
return DrivePullResult(
items=[
DrivePullResult.Item(
key="skills/foo/SKILL.md", local_path=str(Path(local_base) / "skills" / "foo" / "SKILL.md")
)
]
)
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
@ -316,7 +434,7 @@ def test_cli_drive_pull_uses_environment_drive_base_default(
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert captured_kwargs == {"targets": ["skills/foo"], "drive_base": "/env/drive"}
assert captured_kwargs == {"targets": ["skills/foo"], "local_base": "/env/drive"}
assert captured.out.strip() == "/env/drive/skills/foo/SKILL.md"
@ -327,10 +445,16 @@ def test_cli_drive_pull_keeps_historical_drive_base_when_env_is_missing(
monkeypatch.delenv("DIFY_AGENT_STUB_DRIVE_BASE", raising=False)
captured_kwargs: dict[str, object] = {}
def fake_pull_drive_from_environment(*, targets, drive_base):
def fake_pull_drive_from_environment(*, targets, local_base):
captured_kwargs["targets"] = targets
captured_kwargs["drive_base"] = drive_base
return [Path(drive_base) / "skills" / "foo" / "SKILL.md"]
captured_kwargs["local_base"] = local_base
return DrivePullResult(
items=[
DrivePullResult.Item(
key="skills/foo/SKILL.md", local_path=str(Path(local_base) / "skills" / "foo" / "SKILL.md")
)
]
)
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
@ -342,17 +466,44 @@ def test_cli_drive_pull_keeps_historical_drive_base_when_env_is_missing(
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert captured_kwargs == {"targets": ["skills/foo"], "drive_base": "/mnt/drive"}
assert captured_kwargs == {"targets": ["skills/foo"], "local_base": "/mnt/drive"}
assert captured.out.strip() == "/mnt/drive/skills/foo/SKILL.md"
def test_cli_drive_pull_without_targets_pulls_whole_visible_drive(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
captured_kwargs: dict[str, object] = {}
def fake_pull_drive_from_environment(*, targets, local_base):
captured_kwargs["targets"] = targets
captured_kwargs["local_base"] = local_base
return DrivePullResult(
items=[DrivePullResult.Item(key="files/a.txt", local_path=str(Path(local_base) / "files" / "a.txt"))]
)
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", "--to", "/tmp/drive"])
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert captured_kwargs == {"targets": None, "local_base": "/tmp/drive"}
assert captured.out.strip() == "/tmp/drive/files/a.txt"
def test_cli_drive_push_prints_commit_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.push_drive_from_environment",
lambda *, local_path, drive_path, recursive: AgentStubDriveCommitResponse(
lambda *, local_path, drive_path, kind: AgentStubDriveCommitResponse(
items=[
AgentStubDriveItem(
key=drive_path,
@ -361,7 +512,7 @@ def test_cli_drive_push_prints_commit_json(
mime_type="text/markdown",
file_kind="tool_file",
file_id=Path(local_path).name,
value_owned_by_drive=recursive is False,
value_owned_by_drive=kind != "dir",
)
]
),
@ -373,3 +524,87 @@ def test_cli_drive_push_prints_commit_json(
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert json.loads(captured.out)["items"][0]["key"] == "skills/example/SKILL.md"
def test_cli_drive_push_forwards_kind(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
captured_kwargs: dict[str, object] = {}
def fake_push_drive_from_environment(*, local_path, drive_path, kind):
captured_kwargs["local_path"] = local_path
captured_kwargs["drive_path"] = drive_path
captured_kwargs["kind"] = kind
return AgentStubDriveCommitResponse(items=[])
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.push_drive_from_environment",
fake_push_drive_from_environment,
)
with pytest.raises(SystemExit) as exc_info:
main(["drive", "push", "/tmp/skill", "skills/example", "--kind", "skill"])
capsys.readouterr()
assert exc_info.value.code == 0
assert captured_kwargs == {
"local_path": "/tmp/skill",
"drive_path": "skills/example",
"kind": "skill",
}
def test_cli_drive_push_accepts_json_flag(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
captured_kwargs: dict[str, object] = {}
def fake_push_drive_from_environment(*, local_path, drive_path, kind):
captured_kwargs["local_path"] = local_path
captured_kwargs["drive_path"] = drive_path
captured_kwargs["kind"] = kind
return AgentStubDriveCommitResponse(items=[])
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.push_drive_from_environment",
fake_push_drive_from_environment,
)
with pytest.raises(SystemExit) as exc_info:
main(["drive", "push", "/tmp/report.md", "files/report.md", "--json"])
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert json.loads(captured.out) == {"items": []}
assert captured_kwargs == {
"local_path": "/tmp/report.md",
"drive_path": "files/report.md",
"kind": None,
}
def test_cli_drive_push_rejects_recursive_option(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
called = False
def fake_push_drive_from_environment(*, local_path, drive_path, kind):
nonlocal called
called = True
return AgentStubDriveCommitResponse(items=[])
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.push_drive_from_environment",
fake_push_drive_from_environment,
)
with pytest.raises(SystemExit) as exc_info:
main(["drive", "push", "/tmp/dir", "files/dir", "--recursive"])
captured = capsys.readouterr()
assert exc_info.value.code == 2
assert called is False
assert "--recursive" in captured.err

View File

@ -196,6 +196,7 @@ def test_request_agent_stub_drive_manifest_sync_gets_manifest_request() -> None:
"items": [
{
"key": "skills/example/SKILL.md",
"name": "SKILL.md",
"size": 12,
"hash": "sha256:abc",
"mime_type": "text/markdown",
@ -220,6 +221,7 @@ def test_request_agent_stub_drive_manifest_sync_gets_manifest_request() -> None:
http_client.close()
assert response.items[0].key == "skills/example/SKILL.md"
assert response.items[0].model_extra == {"name": "SKILL.md"}
def test_request_agent_stub_drive_commit_sync_posts_commit_request() -> None:

View File

@ -11,6 +11,7 @@ from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubDriveCommitItem,
AgentStubDriveCommitRequest,
AgentStubDriveFileRef,
AgentStubDriveManifestResponse,
AgentStubFileMapping,
agent_stub_connections_url,
agent_stub_drive_base_for_ref,
@ -159,6 +160,7 @@ def test_agent_stub_drive_commit_request_validates_file_refs() -> None:
]
)
assert request.items[0].file_ref is not None
assert request.items[0].file_ref.kind == "tool_file"
with pytest.raises(ValidationError, match="tool_file"):
@ -168,6 +170,15 @@ def test_agent_stub_drive_commit_request_validates_file_refs() -> None:
assert item_without_file_ref.file_ref is None
def test_agent_stub_drive_manifest_response_preserves_extra_item_fields() -> None:
response = AgentStubDriveManifestResponse.model_validate(
{"items": [{"key": "skills/example/SKILL.md", "name": "SKILL.md"}]}
)
assert response.items[0].model_extra == {"name": "SKILL.md"}
assert response.items[0].model_dump(mode="json")["name"] == "SKILL.md"
@pytest.mark.parametrize("transfer_method", ["tool_file", "local_file", "datasource_file"])
def test_agent_stub_file_mapping_rejects_non_remote_with_url(
transfer_method: Literal["tool_file", "local_file", "datasource_file"],

View File

@ -57,6 +57,7 @@ def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_manifes
"items": [
{
"key": "skills/example/SKILL.md",
"name": "SKILL.md",
"size": 12,
"hash": "sha256:abc",
"mime_type": "text/markdown",
@ -82,6 +83,7 @@ def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_manifes
include_download_url=True,
)
assert response.items[0].download_url == "https://files.example.com/download"
assert response.items[0].model_extra == {"name": "SKILL.md"}
asyncio.run(scenario())

View File

@ -48,10 +48,8 @@ def test_layer_config_rejects_unknown_fields() -> None:
def test_drive_layer_is_registered_and_constructible_from_config() -> None:
layer = DifyDriveLayer.from_config_with_settings(
layer = DifyDriveLayer.from_config(
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)

View File

@ -2,27 +2,30 @@
from __future__ import annotations
from pathlib import Path
from typing import cast
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
from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError
from dify_agent.layers.shell import DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult, ShellctlClientFactory
class _FakeExecutionContextConfig(LayerConfig):
tenant_id: str
def _unused_client_factory(_entrypoint: str):
raise AssertionError("shellctl client should not be used by these drive-layer tests")
class _FakeExecutionContextLayer(PlainLayer[NoLayerDeps, _FakeExecutionContextConfig, EmptyRuntimeState]):
type_id = None
def __init__(self, tenant_id: str) -> None:
self.config = _FakeExecutionContextConfig(tenant_id=tenant_id)
def _shell_layer() -> DifyShellLayer:
return DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=cast(ShellctlClientFactory, _unused_client_factory),
)
def _build_layer(tmp_path: Path) -> DifyDriveLayer:
layer = DifyDriveLayer.from_config_with_settings(
def _build_layer() -> DifyDriveLayer:
layer = DifyDriveLayer.from_config(
DifyDriveLayerConfig(
drive_ref="agent-1",
skills=[
@ -43,158 +46,210 @@ def _build_layer(tmp_path: Path) -> DifyDriveLayer:
],
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")})
layer.bind_deps({"shell": _shell_layer()})
return layer
def _remote_result(
output: str,
*,
exit_code: int | None = 0,
truncated: bool = False,
) -> RemoteCommandResult:
return RemoteCommandResult(
job_id="remote-drive-pull",
status="exited",
done=True,
exit_code=exit_code,
output=output,
offset=len(output),
truncated=truncated,
output_path="/tmp/output.log",
)
def _pulled_output() -> str:
return (
"/mnt/drive/agent-1/tender-analyzer\n"
"/mnt/drive/agent-1/files/report.pdf\n"
"__DIFY_DRIVE_MENTIONED_PATH__\ttender-analyzer/SKILL.md\t/mnt/drive/agent-1/tender-analyzer/SKILL.md\n"
"__DIFY_DRIVE_SKILL_BEGIN__\ttender-analyzer/SKILL.md\n"
"# Tender Analyzer\n"
"Use carefully.\n"
"__DIFY_DRIVE_SKILL_END__\ttender-analyzer/SKILL.md\n"
"__DIFY_DRIVE_MENTIONED_PATH__\tfiles/report.pdf\t/mnt/drive/agent-1/files/report.pdf\n"
)
def test_drive_layer_exposes_agent_stub_cli_usage_suffix_prompt() -> None:
layer = _build_layer()
assert len(layer.suffix_prompts) == 1
prompt = layer.suffix_prompts[0]
assert "Other available skills" in prompt
assert "other-skill: Other Skill — Fallback catalog entry." in prompt
assert "`dify-agent drive pull other-skill/`" not in prompt
assert (
'`skill_dir="$(dify-agent drive pull <SKILL_PATH> --to /tmp/drive)"; '
'printf "%s\\n" "$skill_dir"; cat "$skill_dir/SKILL.md"`'
) in prompt
assert "dify-agent drive list [REMOTE_PREFIX]" in prompt
assert "dify-agent drive pull [REMOTE ...] [--to LOCAL_DIR]" in prompt
assert "--to ." in prompt
assert "dify-agent drive push LOCAL_FILE REMOTE_PATH" in prompt
assert "dify-agent drive push LOCAL_DIR REMOTE_PATH --kind skill" in prompt
assert "dify-agent drive push LOCAL_DIR REMOTE_PATH --kind dir" in prompt
assert "dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [--to LOCAL_DIR]" in prompt
assert "dify-agent file download --mapping" in prompt
assert "dify-agent file upload PATH" in prompt
assert '{"transfer_method":"tool_file","reference":"..."}' in prompt
assert "--recursive" not in prompt
assert "--drive-base" not in prompt
@pytest.mark.anyio
async def test_on_context_create_loads_mentioned_targets_into_prompt(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
async def test_on_context_create_pulls_mentioned_targets_through_shell(
monkeypatch: pytest.MonkeyPatch,
) -> None:
layer = _build_layer(tmp_path)
layer = _build_layer()
captured: dict[str, object] = {}
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 fake_run_remote_script(
self: DifyShellLayer,
script: str,
*,
timeout: float = 10.0,
inject_agent_stub_env: bool = False,
) -> RemoteCommandResult:
del self, timeout
captured["script"] = script
captured["inject_agent_stub_env"] = inject_agent_stub_env
return _remote_result(_pulled_output())
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)
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
await layer.on_context_create()
script = captured["script"]
assert isinstance(script, str)
assert captured["inject_agent_stub_env"] is True
assert "base=/mnt/drive/agent-1" in script
assert 'dify-agent drive pull tender-analyzer/ files/report.pdf --to "$base"' in script
assert "cat /mnt/drive/agent-1/tender-analyzer/SKILL.md" in script
prompt = layer.build_prompt_context()
assert "Loaded mentioned skills" in prompt
assert "Path: tender-analyzer" in prompt
assert "Local path: /mnt/drive/agent-1/tender-analyzer" in prompt
assert "Name: Tender Analyzer" not 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
assert "files/report.pdf -> /mnt/drive/agent-1/files/report.pdf" in prompt
assert "Other available skills" not in prompt
@pytest.mark.anyio
async def test_on_context_resume_loads_mentioned_targets_into_prompt(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
async def test_on_context_resume_repulls_mentioned_targets_through_shell(
monkeypatch: pytest.MonkeyPatch,
) -> None:
layer = _build_layer(tmp_path)
layer = _build_layer()
calls = 0
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 fake_run_remote_script(
self: DifyShellLayer,
script: str,
*,
timeout: float = 10.0,
inject_agent_stub_env: bool = False,
) -> RemoteCommandResult:
del self, script, timeout
nonlocal calls
calls += 1
assert inject_agent_stub_env is True
return _remote_result(_pulled_output())
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)
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
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
assert calls == 1
assert "Loaded mentioned skills" in layer.build_prompt_context()
@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)
layer = _build_layer()
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 fake_run_remote_script(
self: DifyShellLayer,
script: str,
*,
timeout: float = 10.0,
inject_agent_stub_env: bool = False,
) -> RemoteCommandResult:
del self, script, timeout, inject_agent_stub_env
output = (
"__DIFY_DRIVE_MENTIONED_PATH__\ttender-analyzer/SKILL.md\t/mnt/drive/agent-1/tender-analyzer/SKILL.md\n"
"__DIFY_DRIVE_SKILL_BEGIN__\ttender-analyzer/SKILL.md\n"
"# Tender Analyzer\n"
"__DIFY_DRIVE_SKILL_END__\ttender-analyzer/SKILL.md\n"
)
return _remote_result(output)
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)
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
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(
async def test_on_context_create_raises_when_shell_pull_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
layer = _build_layer(tmp_path)
layer = _build_layer()
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
del tenant_id, targets
return []
async def fake_run_remote_script(
self: DifyShellLayer,
script: str,
*,
timeout: float = 10.0,
inject_agent_stub_env: bool = False,
) -> RemoteCommandResult:
del self, script, timeout, inject_agent_stub_env
return _remote_result("permission denied\n", exit_code=1)
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
assert items == []
return {}
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
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()
with pytest.raises(DifyDriveLayerError, match="drive mentioned pull failed in shell"):
await layer.on_context_create()
@pytest.mark.anyio
async def test_on_context_create_raises_when_manifest_is_empty_for_mentioned_targets(
async def test_on_context_create_raises_when_shell_output_is_truncated(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
layer = _build_layer(tmp_path)
layer = _build_layer()
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
del tenant_id, targets
return []
async def fake_run_remote_script(
self: DifyShellLayer,
script: str,
*,
timeout: float = 10.0,
inject_agent_stub_env: bool = False,
) -> RemoteCommandResult:
del self, script, timeout, inject_agent_stub_env
return _remote_result(_pulled_output(), truncated=True)
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
assert items == []
return {}
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
monkeypatch.setattr(layer, "_download_items", _download_items)
with pytest.raises(DifyDriveLayerError, match="missing pulled file"):
with pytest.raises(DifyDriveLayerError, match="output was truncated"):
await layer.on_context_create()
def test_parse_shell_pull_output_rejects_unclosed_skill_marker() -> None:
layer = _build_layer()
with pytest.raises(DifyDriveLayerError, match="omitted SKILL.md end marker"):
layer._parse_shell_pull_output("__DIFY_DRIVE_SKILL_BEGIN__\ttender-analyzer/SKILL.md\n# Tender\n")

View File

@ -6,46 +6,142 @@ from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig
def _valid_config() -> dict[str, object]:
return {
"dataset_ids": ["dataset-1"],
"retrieval": {
"mode": "multiple",
"top_k": 4,
},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {
"mode": "multiple",
"top_k": 4,
},
}
],
}
def test_knowledge_base_config_accepts_valid_multiple_mode() -> None:
config = DifyKnowledgeBaseLayerConfig.model_validate(_valid_config())
assert config.dataset_ids == ["dataset-1"]
assert config.retrieval.top_k == 4
assert config.metadata_filtering.mode == "disabled"
assert config.sets[0].dataset_ids == ["dataset-1"]
assert config.sets[0].retrieval.top_k == 4
assert config.sets[0].metadata_filtering.mode == "disabled"
@pytest.mark.parametrize(
"payload, expected_message",
[
({"dataset_ids": [], "retrieval": {"mode": "multiple", "top_k": 4}}, "dataset_ids"),
({"sets": []}, "sets"),
({"tool_name": "knowledge_base_search", **_valid_config()}, "Extra inputs are not permitted"),
({"tool_description": "Search knowledge", **_valid_config()}, "Extra inputs are not permitted"),
({"dataset_ids": ["dataset-1"], "retrieval": {"mode": "multiple"}}, "top_k"),
({"dataset_ids": ["dataset-1"], "retrieval": {"mode": "single"}}, "retrieval.model"),
(
{
"dataset_ids": ["dataset-1"],
"retrieval": {"mode": "multiple", "top_k": 4},
"metadata_filtering": {"mode": "automatic"},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": ""}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
},
"dataset id",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "user_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
},
"query.value",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple"},
}
]
},
"top_k",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "single"},
}
]
},
"retrieval.model",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
"metadata_filtering": {"mode": "automatic"},
}
],
},
"metadata_filtering.model_config",
),
(
{
"dataset_ids": ["dataset-1"],
"retrieval": {"mode": "multiple", "top_k": 4},
"metadata_filtering": {"mode": "manual"},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
"metadata_filtering": {"mode": "manual"},
}
],
},
"metadata_filtering.conditions",
),
(
{
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
{
"id": "docs",
"name": "support kb",
"datasets": [{"id": "dataset-2"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
},
]
},
"names must be unique",
),
],
)
def test_knowledge_base_config_rejects_invalid_inputs(payload: dict[str, object], expected_message: str) -> None:
@ -57,8 +153,7 @@ def test_knowledge_base_config_rejects_observation_limit_smaller_than_result_lim
with pytest.raises(ValidationError, match="max_observation_chars"):
_ = DifyKnowledgeBaseLayerConfig.model_validate(
{
"dataset_ids": ["dataset-1"],
"retrieval": {"mode": "multiple", "top_k": 4},
**_valid_config(),
"max_result_content_chars": 50,
"max_observation_chars": 20,
}

View File

@ -8,7 +8,11 @@ from pydantic_ai import Tool
from agenton.compositor import Compositor, LayerNode, LayerProvider
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.knowledge.client import DifyKnowledgeBaseClientError
from dify_agent.layers.knowledge.client import (
DifyKnowledgeBaseClient,
DifyKnowledgeBaseClientError,
DifyKnowledgeRetrieveResponse,
)
from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig
from dify_agent.layers.knowledge.layer import (
BLANK_QUERY_OBSERVATION,
@ -32,10 +36,23 @@ def _execution_context_config(**overrides: object) -> DifyExecutionContextLayerC
def _knowledge_config(**overrides: object) -> DifyKnowledgeBaseLayerConfig:
payload: dict[str, object] = {
"dataset_ids": ["dataset-1"],
set_payload: dict[str, object] = {
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
for key in ("id", "name", "description", "datasets", "query", "retrieval", "metadata_filtering"):
if key in overrides:
set_payload[key] = overrides.pop(key)
if "dataset_ids" in overrides:
dataset_ids = overrides.pop("dataset_ids")
assert isinstance(dataset_ids, list)
set_payload["datasets"] = [{"id": dataset_id} for dataset_id in dataset_ids]
payload: dict[str, object] = {
"sets": [set_payload],
}
payload.update(overrides)
return DifyKnowledgeBaseLayerConfig.model_validate(payload)
@ -62,7 +79,7 @@ def _knowledge_provider() -> LayerProvider[DifyKnowledgeBaseLayer]:
)
def test_knowledge_layer_exposes_one_query_only_tool_definition() -> None:
def test_knowledge_layer_exposes_one_set_scoped_tool_definition() -> None:
async def scenario() -> None:
compositor = Compositor(
[
@ -82,20 +99,23 @@ def test_knowledge_layer_exposes_one_query_only_tool_definition() -> None:
tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType]
assert isinstance(tool, Tool)
assert tool.name == "knowledge_base_search"
assert tool.description == "Search configured knowledge bases for information relevant to the query."
assert "Pick one configured set_name" in tool.description
assert tool_def is not None
assert (
tool_def.description == "Search configured knowledge bases for information relevant to the query."
)
assert "Pick one configured set_name" in tool_def.description
assert tool_def.parameters_json_schema == {
"type": "object",
"properties": {
"set_name": {
"type": "string",
"enum": ["Support KB"],
"description": "Knowledge set to search.",
},
"query": {
"type": "string",
"description": "Search query for the configured knowledge bases.",
}
"description": "Search query for the selected knowledge set.",
},
},
"required": ["query"],
"required": ["set_name", "query"],
"additionalProperties": False,
}
@ -119,12 +139,105 @@ def test_knowledge_layer_rejects_blank_query_locally() -> None:
) as run:
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
result = await tool.function_schema.call({"query": " "}, None) # pyright: ignore[reportArgumentType]
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
{"set_name": "Support KB", "query": " "}, None
)
assert result == BLANK_QUERY_OBSERVATION
asyncio.run(scenario())
def test_knowledge_layer_exposes_no_tool_when_all_sets_are_user_query(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_retrieve(self: DifyKnowledgeBaseClient, **_kwargs: object) -> DifyKnowledgeRetrieveResponse:
del self
return DifyKnowledgeRetrieveResponse.model_validate({"results": [], "usage": {}})
monkeypatch.setattr(DifyKnowledgeBaseClient, "retrieve", fake_retrieve)
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient() as http_client:
async with compositor.enter(
configs={
"execution_context": _execution_context_config(),
"knowledge": _knowledge_config(query={"mode": "user_query", "value": "release notes"}),
}
) as run:
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
assert await knowledge_layer.get_tools(http_client=http_client) == []
asyncio.run(scenario())
def test_knowledge_layer_fetches_user_query_sets_on_context_entry(monkeypatch: pytest.MonkeyPatch) -> None:
seen_requests: list[dict[str, object]] = []
async def fake_retrieve(self: DifyKnowledgeBaseClient, **kwargs: object) -> DifyKnowledgeRetrieveResponse:
del self
seen_requests.append(kwargs)
return DifyKnowledgeRetrieveResponse.model_validate(
{
"results": [
{
"metadata": {
"_source": "knowledge",
"dataset_name": "Docs",
"document_name": "Release.md",
"score": 0.8,
},
"title": "Release",
"files": [],
"content": "Version notes",
"summary": None,
}
],
"usage": {},
}
)
monkeypatch.setattr(DifyKnowledgeBaseClient, "retrieve", fake_retrieve)
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}),
]
)
async with compositor.enter(
configs={
"execution_context": _execution_context_config(),
"knowledge": _knowledge_config(query={"mode": "user_query", "value": "release notes"}),
}
) as run:
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
assert len(seen_requests) == 1
assert seen_requests[0]["query"] == "release notes"
assert seen_requests[0]["dataset_ids"] == ["dataset-1"]
assert knowledge_layer.runtime_state.eager_config_fingerprint
assert knowledge_layer.runtime_state.eager_results[0].status == "success"
assert knowledge_layer.user_prompts == [
"Knowledge retrieval results:\n\n"
"Set: Support KB\n"
"Query: release notes\n"
"Results:\n"
"1. Title: Release\n"
" Dataset: Docs\n"
" Document: Release.md\n"
" Score: 0.8\n"
" Content: Version notes"
]
await knowledge_layer.on_context_resume()
assert len(seen_requests) == 1
asyncio.run(scenario())
@pytest.mark.parametrize(
("field_name", "field_value"),
[
@ -199,7 +312,9 @@ def test_knowledge_layer_formats_results_and_truncates_observation() -> None:
) as run:
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
{"set_name": "Support KB", "query": "reset"}, None
)
assert result.startswith("Knowledge base search results:\n1. Title: Guide")
assert "Dataset: Docs" in result
assert "Document: Guide.md" in result
@ -229,7 +344,9 @@ def test_knowledge_layer_returns_no_results_observation() -> None:
) as run:
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
{"set_name": "Support KB", "query": "reset"}, None
)
assert result == NO_RESULTS_OBSERVATION
asyncio.run(scenario())
@ -256,7 +373,9 @@ def test_knowledge_layer_converts_retryable_failures_into_observation() -> None:
) as run:
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
{"set_name": "Support KB", "query": "reset"}, None
)
assert result == TEMPORARY_UNAVAILABLE_OBSERVATION
asyncio.run(scenario())
@ -289,7 +408,9 @@ def test_knowledge_layer_converts_retryable_transport_failures_into_observation(
) as run:
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
{"set_name": "Support KB", "query": "reset"}, None
)
assert result == TEMPORARY_UNAVAILABLE_OBSERVATION
asyncio.run(scenario())
@ -317,7 +438,9 @@ def test_knowledge_layer_raises_non_retryable_client_errors() -> None:
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
with pytest.raises(DifyKnowledgeBaseClientError) as exc_info:
await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
await tool.function_schema.call( # pyright: ignore[reportArgumentType]
{"set_name": "Support KB", "query": "reset"}, None
)
assert exc_info.value.status_code == 403
asyncio.run(scenario())
@ -343,7 +466,9 @@ def test_knowledge_layer_raises_for_malformed_success_responses() -> None:
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
with pytest.raises(DifyKnowledgeBaseClientError) as exc_info:
await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
await tool.function_schema.call( # pyright: ignore[reportArgumentType]
{"set_name": "Support KB", "query": "reset"}, None
)
assert exc_info.value.error_code == "invalid_response"
assert exc_info.value.retryable is False
@ -411,7 +536,9 @@ def test_knowledge_layer_sends_execution_context_and_static_config_to_inner_api(
) as run:
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
{"set_name": "Support KB", "query": "reset"}, None
)
assert result == NO_RESULTS_OBSERVATION
asyncio.run(scenario())

View File

@ -29,6 +29,7 @@ def test_shell_layer_config_defaults_and_forbids_unknown_fields() -> None:
config = DifyShellLayerConfig()
assert config.model_dump() == {
"agent_stub_drive_ref": None,
"cli_tools": [],
"env": [],
"secret_refs": [],
@ -51,6 +52,7 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None:
],
env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo")],
secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="credential-1")],
agent_stub_drive_ref="agent-1",
sandbox=DifyShellSandboxConfig(provider="independent", config={"cpu": 2}),
)
@ -59,6 +61,7 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None:
assert config.cli_tools[0].secret_refs[0].ref == "credential-2"
assert config.env[0].name == "PROJECT_NAME"
assert config.secret_refs[0].ref == "credential-1"
assert config.agent_stub_drive_ref == "agent-1"
assert config.sandbox is not None
assert config.sandbox.config == {"cpu": 2}

View File

@ -14,8 +14,6 @@ from dify_agent.agent_stub.server.shell_agent_stub_env import (
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 (
@ -238,14 +236,6 @@ 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,
@ -666,7 +656,7 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() ->
client = FakeShellctlClient(run_handler=run_handler)
layer = DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(),
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: client,
agent_stub_api_base_url="https://agent.example.com/agent-stub",
@ -674,7 +664,7 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() ->
f"token-for:{execution_context.tenant_id}:{session_id}"
),
)
layer.deps = layer.deps_type(drive=_drive_layer(), execution_context=_execution_context_layer())
layer.deps = layer.deps_type(execution_context=_execution_context_layer())
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
@ -793,7 +783,7 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads()
client = FakeShellctlClient(run_handler=run_handler)
layer = DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(),
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: client,
agent_stub_api_base_url="https://agent.example.com/agent-stub",
@ -801,7 +791,7 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads()
f"token-for:{execution_context.tenant_id}:{session_id}"
),
)
layer.deps = layer.deps_type(drive=_drive_layer(), execution_context=_execution_context_layer())
layer.deps = layer.deps_type(execution_context=_execution_context_layer())
async def scenario() -> None:
async with layer.resource_context():

View File

@ -995,7 +995,7 @@ def test_runner_passes_dynamic_dify_knowledge_tools_to_agent(monkeypatch: pytest
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
async def fake_get_tools(self: DifyKnowledgeBaseLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
assert self.config.dataset_ids == ["dataset-1"]
assert self.config.sets[0].dataset_ids == ["dataset-1"]
assert http_client.headers.get("X-Test-Client") == "dify-api"
return [Tool(knowledge_tool, name="knowledge_base_search")]
@ -1055,8 +1055,15 @@ def test_runner_passes_dynamic_dify_knowledge_tools_to_agent(monkeypatch: pytest
deps={"execution_context": "execution_context"},
config=DifyKnowledgeBaseLayerConfig.model_validate(
{
"dataset_ids": ["dataset-1"],
"retrieval": {"mode": "multiple", "top_k": 4},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
],
}
),
),

View File

@ -231,8 +231,15 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
knowledge_layer = knowledge_provider.create_layer(
DifyKnowledgeBaseLayerConfig.model_validate(
{
"dataset_ids": ["dataset-1"],
"retrieval": {"mode": "multiple", "top_k": 2},
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "dataset-1"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 2},
}
],
}
)
)

View File

@ -0,0 +1,81 @@
from __future__ import annotations
from typing import ClassVar
from fastapi import FastAPI
import dify_agent.server.observability as observability
from dify_agent.server.observability import configure_server_observability
class FakeLogfireModule:
configure_calls: ClassVar[list[dict[str, object]]] = []
fastapi_calls: ClassVar[list[dict[str, object]]] = []
httpx_calls: ClassVar[list[dict[str, object]]] = []
redis_calls: ClassVar[list[dict[str, object]]] = []
pydantic_ai_calls: ClassVar[list[dict[str, object]]] = []
@classmethod
def reset(cls) -> None:
cls.configure_calls.clear()
cls.fastapi_calls.clear()
cls.httpx_calls.clear()
cls.redis_calls.clear()
cls.pydantic_ai_calls.clear()
@classmethod
def configure(cls, **kwargs: object) -> None:
cls.configure_calls.append(kwargs)
@classmethod
def instrument_fastapi(cls, app: FastAPI, **kwargs: object) -> None:
cls.fastapi_calls.append({"app": app, **kwargs})
@classmethod
def instrument_httpx(cls, **kwargs: object) -> None:
cls.httpx_calls.append(kwargs)
@classmethod
def instrument_redis(cls, **kwargs: object) -> None:
cls.redis_calls.append(kwargs)
@classmethod
def instrument_pydantic_ai(cls, **kwargs: object) -> None:
cls.pydantic_ai_calls.append(kwargs)
def test_configure_server_observability_keeps_remote_export_token_gated_by_logfire_env(monkeypatch) -> None:
FakeLogfireModule.reset()
monkeypatch.setattr(observability, "logfire", FakeLogfireModule)
monkeypatch.setattr(observability, "_global_instrumentation_ready", False)
app = FastAPI()
configure_server_observability(app)
assert FakeLogfireModule.configure_calls == [
{
"send_to_logfire": "if-token-present",
"inspect_arguments": False,
}
]
def test_configure_server_observability_instruments_server_boundaries_once(monkeypatch) -> None:
FakeLogfireModule.reset()
monkeypatch.setattr(observability, "logfire", FakeLogfireModule)
monkeypatch.setattr(observability, "_global_instrumentation_ready", False)
first_app = FastAPI()
second_app = FastAPI()
configure_server_observability(first_app)
configure_server_observability(second_app)
assert FakeLogfireModule.httpx_calls == [{}]
assert FakeLogfireModule.redis_calls == [{}]
assert FakeLogfireModule.pydantic_ai_calls == [{}]
assert FakeLogfireModule.fastapi_calls == [
{"app": first_app},
{"app": second_app},
]
assert first_app.state.dify_agent_logfire_instrumented is True
assert second_app.state.dify_agent_logfire_instrumented is True

View File

@ -115,7 +115,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
"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']",
"assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig']",
"assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeDatasetConfig', 'DifyKnowledgeEagerResult', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeQueryConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig', 'DifyKnowledgeRuntimeState', 'DifyKnowledgeSetConfig']",
"assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']",
"assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellCliToolConfig', 'DifyShellEnvVarConfig', 'DifyShellLayerConfig', 'DifyShellSandboxConfig', 'DifyShellSecretRefConfig']",
],

View File

@ -19,6 +19,7 @@ SERVER_RUNTIME_DEPENDENCIES = {
"graphon==0.5.2",
"jsonschema>=4.23.0,<5.0.0",
"jwcrypto>=1.5.6,<2",
"logfire[fastapi,httpx,redis]>=4.37.0,<5.0.0",
"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",

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