mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 14:51:13 +08:00
Merge ab4680db47 into 4f4ac27de2
This commit is contained in:
commit
ec0fc0cc2f
@ -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(
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
67
api/controllers/files/agent_drive_archive.py
Normal file
67
api/controllers/files/agent_drive_archive.py
Normal 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
|
||||
@ -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"]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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'
|
||||
)
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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]]] = {}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
63
api/services/agent/knowledge_datasets.py
Normal file
63
api/services/agent/knowledge_datasets.py
Normal 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]
|
||||
@ -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 _:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ─────
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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=".")
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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§], "
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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": "产品手册"}
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
1
api/uv.lock
generated
@ -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" },
|
||||
|
||||
@ -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",
|
||||
|
||||
169
dify-agent/src/dify_agent/agent_stub/_drive_materialization.py
Normal file
169
dify-agent/src/dify_agent/agent_stub/_drive_materialization.py
Normal 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",
|
||||
]
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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]
|
||||
|
||||
47
dify-agent/src/dify_agent/server/observability.py
Normal file
47
dify-agent/src/dify_agent/server/observability.py
Normal 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"]
|
||||
@ -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} == {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
),
|
||||
|
||||
@ -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},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@ -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
|
||||
@ -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']",
|
||||
],
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user