Compare commits

...
Author SHA1 Message Date
Yansong Zhang ec995c2a8a fix(agent-v2): skip invalid backing agent ids in list state 2026-06-25 09:38:58 +08:00
Yansong Zhang f83ef26edc feat(agent-v2): add agent draft build lifecycle 2026-06-24 21:09:20 +08:00
Joel 611a7c5081 chore: some tooltip and header bg 2026-06-24 19:59:37 +08:00
yyh b72a51cf65 fix(agent-v2): recover inline binding during draft refresh 2026-06-24 19:33:16 +08:00
yyh 47de929a00 fix(agent-v2): persist pending inline nodes during creation 2026-06-24 19:33:16 +08:00
Joel 614c23da32 chore: remove cli tool 2026-06-24 17:57:13 +08:00
yyh 6e1bfdf4ac style: pretty skeleton 2026-06-24 17:53:28 +08:00
Joel 2581267a1a chore: build chat preview 2026-06-24 17:50:25 +08:00
yyh d8b8439047 Merge branch 'feat/agent-v2' of https://github.com/langgenius/dify into feat/agent-v2 2026-06-24 17:36:28 +08:00
yyh d3db19e20d Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-24 17:36:22 +08:00
Joel d96ba750db chore: only show debug view because of backend not support preveiew chat 2026-06-24 17:36:21 +08:00
yyh eecb3bb676 Merge branch 'feat/agent-v2' of https://github.com/langgenius/dify into feat/agent-v2 2026-06-24 17:36:17 +08:00
Yansong Zhang 865426f2f5 fix(agent): import tool file model correctly 2026-06-24 17:35:57 +08:00
yyh 41ffd27f2a fix(agent-v2): sync composer cache after inline creation 2026-06-24 17:35:57 +08:00
Yansong Zhang a6a6bc322b feat(agent): version agent soul drive file refs 2026-06-24 17:23:49 +08:00
Yansong Zhang 83dd03f430 feat(agent): version drive refs in agent soul 2026-06-24 17:23:35 +08:00
yyh 1ce65bf1c9 feat: router push to agent details page when create agent 2026-06-24 17:19:10 +08:00
yyh c5558b562c Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-24 17:11:22 +08:00
yyh 1bac0a1a40 feat: agent roster webapp settings 2026-06-24 17:06:38 +08:00
yyh 5bb8658b0d fix: use timestamp hook 2026-06-24 16:19:55 +08:00
yyh a656e514aa fix: no console api 2026-06-24 16:18:00 +08:00
Joel fe274fc28c feat: build and preview chat 2026-06-24 15:53:41 +08:00
yyh 63e91b40ae Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-24 15:48:37 +08:00
yyh dbb7e36ab9 Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-24 15:32:22 +08:00
yyh 4ddc9ceb5c feat: embed agent webapp 2026-06-24 15:26:27 +08:00
yyh 01c138e7a9 fix(api): site dto 2026-06-24 14:47:30 +08:00
yyh 71df1572b7 fix: use new icon in agent v2 node 2026-06-24 14:08:32 +08:00
yyh 5d0ec54e8c Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-24 13:51:02 +08:00
Joel ac80d95aed chore: split hooks frorm agent 2026-06-24 13:43:08 +08:00
yyh b1feacca6b Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-24 11:28:14 +08:00
yyh 7ef6c13d0d feat(agent-v2): wire workflow agent copy and roster save 2026-06-23 21:31:49 +08:00
yyh 673d84073b Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-23 20:46:06 +08:00
yyh 3d24e21709 feat: add roster beta badge 2026-06-23 20:11:41 +08:00
yyh f11b09abf1 feat(webapp): customize 2026-06-23 20:01:50 +08:00
yyh 238b9202d6 fix: stabilize sidebar nav active layout 2026-06-23 20:00:54 +08:00
yyh 573f9de17a fix(agent-v2): update configure nav icon 2026-06-23 19:57:36 +08:00
yyh d6e902e5a7 fix(agent-v2): rename configure nav label 2026-06-23 19:48:31 +08:00
yyh bf5caeb9f1 fix: new api key modal 2026-06-23 19:36:43 +08:00
yyh 1e17c0f314 feat: access point settings 2026-06-23 18:57:55 +08:00
yyh 73c9017e63 fix(web): update agent access settings icon 2026-06-23 18:34:01 +08:00
yyh 6dd394edee fix(agent-v2): open workflow references in new tab 2026-06-23 18:29:25 +08:00
yyh 53b59eea8b feat: support nested agent output children 2026-06-23 18:14:34 +08:00
Yanli 盐粒 c9a3fa9a45 Merge remote-tracking branch 'origin/feat/agent-v2' into feat/agent-v2 2026-06-23 17:57:54 +08:00
Joel bf01ad417c fix: publish state 2026-06-23 17:54:32 +08:00
Joel 6e1b7d806d chore: cli input placeholder 2026-06-23 17:54:30 +08:00
Yanli 盐粒 7bb4b67885 fix: relax knowledge retrieval usage schema 2026-06-23 17:35:10 +08:00
Yanli 盐粒 8203d42233 Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-23 16:54:02 +08:00
Yanli 盐粒 3efc1f93b9 feat(web): support agent v2 knowledge sets 2026-06-23 16:43:37 +08:00
Yanli 盐粒 c06d924094 feat: wire agent v2 knowledge sets backend 2026-06-23 16:43:10 +08:00
Joel c3cb134e73 chore: missing files 2026-06-23 16:26:12 +08:00
Joel dd3ba72c2c chore: refresh agent debug conversatation id 2026-06-23 16:26:12 +08:00
Joel 44a4fb8888 fix: missing dep 2026-06-23 16:26:11 +08:00
Joel 6c05219af8 fix: knowledge modal not anim 2026-06-23 16:26:09 +08:00
Joel ce5033c554 fix: inline agent panel not always show 2026-06-23 16:26:09 +08:00
Joel 31ee7bb467 fix: public and Knowledge no mask 2026-06-23 16:26:09 +08:00
181 changed files with 13311 additions and 2475 deletions
+2 -2
View File
@@ -312,7 +312,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,
@@ -513,7 +513,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,
@@ -137,6 +137,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,
@@ -271,6 +272,7 @@ class AgentComposerValidateApi(Resource):
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=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,
+145 -1
View File
@@ -56,6 +56,7 @@ from libs.login import login_required
from models import Account
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 +66,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
@@ -250,6 +251,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 +292,9 @@ register_schema_models(
AgentAppCreatePayload,
AgentAppUpdatePayload,
AgentAppCopyPayload,
AgentPublishPayload,
AgentBuildDraftCheckoutPayload,
ComposerSavePayload,
AgentApiStatusPayload,
AgentInviteOptionsQuery,
AgentLogsQuery,
@@ -277,6 +311,10 @@ register_response_schema_models(
AgentAppDetailWithSite,
AgentAppPartial,
AgentDebugConversationRefreshResponse,
AgentPublishResponse,
AgentBuildDraftResponse,
AgentBuildDraftApplyResponse,
AgentSimpleResultResponse,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentConfigSnapshotRestoreResponse,
@@ -583,6 +621,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__])
+61 -17
View File
@@ -38,6 +38,7 @@ from services.agent.skill_tool_inference_service import (
SkillToolInferenceResult,
SkillToolInferenceService,
)
from services.agent.soul_files_service import AgentSoulFilesService
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
@@ -181,6 +182,22 @@ def _agent_not_bound() -> tuple[dict[str, str], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _sync_active_soul_files(
*,
tenant_id: str,
agent_id: str,
account_id: str,
committed_items: list[dict[str, Any]],
) -> None:
AgentSoulFilesService.sync_drive_commit_to_active_soul(
tenant_id=tenant_id,
agent_id=agent_id,
account_id=account_id,
committed_items=committed_items,
)
db.session.commit()
def _upload_skill_for_app(*, current_user: Account, app_model: App):
"""Upload one skill package and commit its normalized files into the agent drive."""
@@ -195,8 +212,9 @@ def _upload_skill_for_app(*, current_user: Account, app_model: App):
upload = request.files["file"]
content = upload.stream.read()
standardize_service = SkillStandardizeService()
try:
result = SkillStandardizeService().standardize(
result = standardize_service.standardize(
content=content,
filename=upload.filename or "",
tenant_id=app_model.tenant_id,
@@ -205,6 +223,12 @@ def _upload_skill_for_app(*, current_user: Account, app_model: App):
)
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
_sync_active_soul_files(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
committed_items=standardize_service.last_committed_items,
)
return result, 201
@@ -243,6 +267,12 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
_sync_active_soul_files(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
committed_items=committed,
)
row = committed[0]
return {
@@ -267,15 +297,13 @@ def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
try:
result = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[DriveCommitItem(key=key, file_ref=None)],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
result = [{"key": key, "removed": True}]
_sync_active_soul_files(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
committed_items=result,
)
removed_keys = [item["key"] for item in result if item.get("removed")]
return {"result": "success", "removed_keys": removed_keys}
@@ -290,18 +318,34 @@ def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, a
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
result = AgentDriveService().commit(
agent_soul = AgentSoulFilesService.active_agent_soul(
session=db.session,
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
removed_keys = [item["key"] for item in result if item.get("removed")]
skill_prefix = f"{slug}/"
removed_keys = [
file_ref.drive_key
for skill in agent_soul.files.skills
if (
skill.path
or (AgentSoulFilesService.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else "")
).strip("/")
== slug
for file_ref in skill.file_refs
if file_ref.drive_key
]
if f"{slug}/SKILL.md" not in removed_keys:
removed_keys.append(f"{slug}/SKILL.md")
result = [{"key": key, "removed": True} for key in removed_keys if key.startswith(skill_prefix)]
_sync_active_soul_files(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
committed_items=result,
)
return {"result": "success", "removed_keys": removed_keys}
@@ -28,10 +28,13 @@ 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 account_initialization_required, setup_required, with_current_tenant_id
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
from models.agent import AgentDriveFileKind
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent.soul_files_service import AgentSoulFilesService
from services.agent_drive_service import AgentDriveError, AgentDriveService
@@ -160,6 +163,111 @@ def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
def _versioned_manifest(*, tenant_id: str, agent_id: str, prefix: str = "") -> list[dict[str, Any]]:
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
normalized_prefix = prefix.strip().lstrip("/")
skill_prefixes = AgentSoulFilesService.allowed_skill_prefixes(agent_soul)
if normalized_prefix and any(normalized_prefix.startswith(p) for p in skill_prefixes):
return AgentSoulFilesService.list_manifest_items(
session=db.session,
tenant_id=tenant_id,
agent_id=agent_id,
prefix=normalized_prefix,
)
return AgentSoulFilesService.list_files(
session=db.session,
tenant_id=tenant_id,
agent_id=agent_id,
prefix=normalized_prefix,
)
def _versioned_skills(*, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
return AgentSoulFilesService.list_skills(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
def _assert_key_in_active_soul(*, tenant_id: str, agent_id: str, key: str) -> None:
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
if not AgentSoulFilesService.key_allowed_by_soul(agent_soul=agent_soul, key=key):
raise AgentDriveError(
"drive_key_not_in_agent_soul",
"drive key is not part of the active Agent Soul version",
status_code=404,
)
def _file_ref_for_active_soul(*, tenant_id: str, agent_id: str, key: str):
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
file_ref = AgentSoulFilesService.file_ref_for_key(agent_soul=agent_soul, key=key)
if file_ref is None and not AgentSoulFilesService.key_allowed_by_soul(agent_soul=agent_soul, key=key):
raise AgentDriveError(
"drive_key_not_in_agent_soul",
"drive key is not part of the active Agent Soul version",
status_code=404,
)
return file_ref
def _file_kind_from_ref(file_ref) -> AgentDriveFileKind | None:
raw = file_ref.transfer_method or ("upload_file" if file_ref.upload_file_id else None)
if raw is None:
return None
try:
return AgentDriveFileKind(raw)
except ValueError as exc:
raise AgentDriveError("invalid_drive_file_ref", "Agent Soul file ref has invalid transfer method") from exc
def _preview_versioned_file(*, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
file_ref = _file_ref_for_active_soul(tenant_id=tenant_id, agent_id=agent_id, key=key)
if file_ref is None:
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=agent_id, key=key)
file_kind = _file_kind_from_ref(file_ref)
file_id = file_ref.file_id or file_ref.upload_file_id
if file_kind is None or not file_id:
raise AgentDriveError("invalid_drive_file_ref", "Agent Soul file ref is missing file id", status_code=404)
return AgentDriveService().preview_file_ref(
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
file_kind=file_kind,
file_id=file_id,
size=file_ref.get("size"),
)
def _download_versioned_file(*, tenant_id: str, agent_id: str, key: str) -> str:
file_ref = _file_ref_for_active_soul(tenant_id=tenant_id, agent_id=agent_id, key=key)
if file_ref is None:
return AgentDriveService().download_url(tenant_id=tenant_id, agent_id=agent_id, key=key)
file_kind = _file_kind_from_ref(file_ref)
file_id = file_ref.file_id or file_ref.upload_file_id
if file_kind is None or not file_id:
raise AgentDriveError("invalid_drive_file_ref", "Agent Soul file ref is missing file id", status_code=404)
return AgentDriveService().download_url_for_ref(
tenant_id=tenant_id,
agent_id=agent_id,
file_kind=file_kind,
file_id=file_id,
)
def _assert_skill_in_active_soul(*, tenant_id: str, agent_id: str, skill_path: str) -> None:
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
wanted = skill_path.strip().strip("/")
for skill in agent_soul.files.skills:
path = skill.path
if not path and skill.skill_md_key:
path = AgentSoulFilesService.skill_path_from_key(skill.skill_md_key)
if path == wanted:
return
raise AgentDriveError(
"skill_not_in_agent_soul",
"skill is not part of the active Agent Soul version",
status_code=404,
)
def _json_response(data: Mapping[str, Any]):
return Response(
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
@@ -184,7 +292,7 @@ class AgentDriveListByAgentApi(Resource):
query = query_params_from_request(AgentDriveListByAgentQuery)
resolve_agent_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)
items = _versioned_manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
except AgentDriveError as exc:
return _handle(exc)
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@@ -203,7 +311,7 @@ class AgentDriveSkillListByAgentApi(Resource):
def get(self, tenant_id: str, agent_id: UUID):
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
items = _versioned_skills(tenant_id=tenant_id, agent_id=str(agent_id))
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@@ -222,6 +330,7 @@ class AgentDriveSkillInspectByAgentApi(Resource):
def get(self, tenant_id: str, agent_id: UUID, skill_path: str):
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
_assert_skill_in_active_soul(tenant_id=tenant_id, agent_id=str(agent_id), skill_path=skill_path)
return _json_response(
AgentDriveService().inspect_skill(
tenant_id=tenant_id,
@@ -247,7 +356,7 @@ class AgentDrivePreviewByAgentApi(Resource):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_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)
return _preview_versioned_file(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
return _handle(exc)
@@ -266,7 +375,7 @@ class AgentDriveDownloadByAgentApi(Resource):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
url = _download_versioned_file(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
@@ -288,7 +397,7 @@ class AgentDriveListApi(Resource):
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
items = _versioned_manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
except AgentDriveError as exc:
return _handle(exc)
# the inner manifest exposes file_id for agent-side pulls; the console
@@ -312,7 +421,7 @@ class AgentDriveSkillListApi(Resource):
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id)
items = _versioned_skills(tenant_id=app_model.tenant_id, agent_id=agent_id)
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@@ -340,6 +449,7 @@ class AgentDriveSkillInspectApi(Resource):
if not agent_id:
return _agent_not_bound()
try:
_assert_skill_in_active_soul(tenant_id=app_model.tenant_id, agent_id=agent_id, skill_path=skill_path)
return _json_response(
AgentDriveService().inspect_skill(
tenant_id=app_model.tenant_id,
@@ -367,7 +477,7 @@ class AgentDrivePreviewApi(Resource):
if not agent_id:
return _agent_not_bound()
try:
return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
return _preview_versioned_file(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
except AgentDriveError as exc:
return _handle(exc)
@@ -388,7 +498,7 @@ class AgentDriveDownloadApi(Resource):
if not agent_id:
return _agent_not_bound()
try:
url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
url = _download_versioned_file(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
+3 -3
View File
@@ -331,7 +331,7 @@ class ModelConfig(ResponseModel):
return to_timestamp(value)
class Site(ResponseModel):
class AppDetailSiteResponse(ResponseModel):
access_token: str | None = Field(default=None, validation_alias="code")
code: str | None = None
title: str | None = None
@@ -461,7 +461,7 @@ class AppDetailWithSite(AppDetail):
api_base_url: str | None = None
max_active_requests: int | None = None
deleted_tools: list[DeletedTool] = Field(default_factory=list)
site: Site | None = None
site: AppDetailSiteResponse | None = None
# For Agent App type: the roster Agent backing this app (None otherwise).
bound_agent_id: str | None = None
# For Agent App responses exposed through /agent.
@@ -546,7 +546,7 @@ register_schema_models(
WorkflowPartial,
ModelConfigPartial,
ModelConfig,
Site,
AppDetailSiteResponse,
DeletedTool,
AppDetail,
AppExportResponse,
@@ -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
@@ -17,6 +17,9 @@ from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.plugin.wraps import get_user
from controllers.inner_api.wraps import plugin_inner_api_only
from extensions.ext_database import db
from models.agent import AgentDriveFileKind
from services.agent.soul_files_service import AgentSoulFilesService
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
@@ -35,6 +38,42 @@ def _error_response(exc: AgentDriveError) -> tuple[dict[str, str], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
def _versioned_manifest(
*,
tenant_id: str,
agent_id: str,
prefix: str = "",
include_download_url: bool = False,
) -> list[dict[str, object]]:
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
normalized_prefix = prefix.strip().lstrip("/")
items = AgentSoulFilesService.list_manifest_items(
session=db.session,
tenant_id=tenant_id,
agent_id=agent_id,
prefix=normalized_prefix,
)
skill_prefixes = AgentSoulFilesService.allowed_skill_prefixes(agent_soul)
if normalized_prefix and not any(normalized_prefix.startswith(p) for p in skill_prefixes):
allowed_keys = AgentSoulFilesService.allowed_drive_keys(agent_soul)
items = [item for item in items if item.get("key") in allowed_keys]
if include_download_url:
for item in items:
file_kind = item.get("file_kind")
file_id = item.get("file_id")
if not file_kind or not file_id:
continue
try:
item["download_url"] = AgentDriveService.resolve_download_url_for_ref(
tenant_id=tenant_id,
file_kind=AgentDriveFileKind(str(file_kind)),
file_id=str(file_id),
)
except ValueError:
item["download_url"] = None
return items
@inner_api_ns.route("/drive/<string:drive_ref>/manifest")
class AgentDriveManifestApi(Resource):
@setup_required
@@ -48,7 +87,7 @@ class AgentDriveManifestApi(Resource):
if not tenant_id:
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
include_download_url = (request.args.get("include_download_url") or "").lower() in ("1", "true", "yes")
items = AgentDriveService().manifest(
items = _versioned_manifest(
tenant_id=tenant_id,
agent_id=agent_id,
prefix=request.args.get("prefix", ""),
@@ -71,7 +110,7 @@ class AgentDriveSkillsApi(Resource):
tenant_id = (request.args.get("tenant_id") or "").strip()
if not tenant_id:
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id)
items = AgentSoulFilesService.list_skills(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
except AgentDriveError as exc:
return _error_response(exc)
return {"items": items}
@@ -97,6 +136,13 @@ class AgentDriveCommitApi(Resource):
agent_id=agent_id,
items=body.items,
)
AgentSoulFilesService.sync_drive_commit_to_active_soul(
tenant_id=body.tenant_id,
agent_id=agent_id,
account_id=user.id,
committed_items=items,
)
db.session.commit()
except AgentDriveError as exc:
return _error_response(exc)
return {"items": items}
+116 -22
View File
@@ -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,7 +439,14 @@ 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,
@@ -432,39 +457,108 @@ class AgentAppGenerator(MessageBasedAppGenerator):
)
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,
@@ -60,11 +71,12 @@ from services.agent.prompt_mentions import (
expand_prompt_mentions,
parse_prompt_mentions,
)
from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref
from services.agent.soul_files_service import AgentSoulFilesService
from services.agent_drive_service import decode_drive_mention_ref
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 +559,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:
@@ -616,13 +670,19 @@ def build_drive_aware_soul_mention_resolver(
tenant_id: str,
agent_id: str,
):
"""Resolve skill/file mentions against the agent drive and everything else via Agent Soul."""
"""Resolve skill/file mentions against versioned Agent Soul refs and everything else via Agent Soul."""
base_resolver = build_soul_mention_resolver(agent_soul)
drive_service = AgentDriveService()
skill_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
skill_names_by_key = {skill["skill_md_key"]: skill["name"] for skill in skill_catalog}
drive_keys = {item["key"] for item in drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)}
skill_names_by_key = {
skill.skill_md_key: skill.name
for skill in agent_soul.files.skills
if skill.skill_md_key and skill.name
}
file_names_by_key = {
file_ref.drive_key: file_ref.name or file_ref.drive_key.rsplit("/", 1)[-1]
for file_ref in agent_soul.files.files
if file_ref.drive_key
}
def _resolve(mention: object) -> str | None:
if not hasattr(mention, "kind") or not hasattr(mention, "ref_id"):
@@ -635,9 +695,7 @@ def build_drive_aware_soul_mention_resolver(
return skill_names_by_key.get(decoded_key) or label or decoded_key
if kind == MentionKind.FILE:
decoded_key = decode_drive_mention_ref(ref_id)
if decoded_key in drive_keys:
return decoded_key.rsplit("/", 1)[-1]
return label or decoded_key
return file_names_by_key.get(decoded_key) or label or decoded_key
return base_resolver(cast(Any, mention))
return _resolve
@@ -649,7 +707,7 @@ def build_drive_layer_config(
tenant_id: str,
agent_id: str | None,
) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]:
"""Derive drive runtime catalog + prompt-mentioned eager-pull keys from the drive."""
"""Derive drive runtime catalog + prompt-mentioned eager-pull keys from Agent Soul refs."""
mentioned_drive_refs = [
decode_drive_mention_ref(mention.ref_id)
@@ -668,10 +726,22 @@ def build_drive_layer_config(
}
]
drive_service = AgentDriveService()
skills_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
manifest_items = drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)
manifest_by_key = {item["key"]: item for item in manifest_items}
skills_catalog = [
{
"path": skill.path or AgentSoulFilesService.skill_path_from_key(skill.skill_md_key),
"name": skill.name or skill.path or skill.skill_md_key,
"description": skill.description or "",
"skill_md_key": skill.skill_md_key,
"archive_key": skill.full_archive_key,
}
for skill in agent_soul.files.skills
if skill.skill_md_key
]
soul_file_keys = {
key
for key in AgentSoulFilesService.allowed_drive_keys(agent_soul)
if key not in {skill["skill_md_key"] for skill in skills_catalog}
}
skill_keys = {skill["skill_md_key"] for skill in skills_catalog}
warnings: list[dict[str, str]] = []
mentioned_skill_keys: list[str] = []
@@ -680,7 +750,7 @@ def build_drive_layer_config(
if drive_key in skill_keys:
mentioned_skill_keys.append(drive_key)
continue
if drive_key in manifest_by_key:
if drive_key in soul_file_keys:
mentioned_file_keys.append(drive_key)
continue
warnings.append(
@@ -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,
+38 -2
View File
@@ -6,6 +6,7 @@ from pydantic import Field, field_validator
from fields.base import ResponseModel
from libs.helper import to_timestamp
from models.agent import (
AgentConfigDraftType,
AgentConfigRevisionOperation,
AgentIconType,
AgentKind,
@@ -16,8 +17,10 @@ from models.agent import (
)
from models.agent_config_entities import (
AgentCliToolConfig,
AgentFileRefConfig,
AgentHumanContactConfig,
AgentKnowledgeDatasetConfig,
AgentSkillRefConfig,
AgentSoulConfig,
DeclaredOutputConfig,
DeclaredOutputType,
@@ -47,6 +50,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
@@ -292,12 +307,18 @@ 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
status: AgentStatus
active_config_snapshot_id: str | None = None
@@ -350,7 +371,8 @@ 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
@@ -400,11 +422,25 @@ 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)
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
files: list[AgentFileRefConfig] = Field(default_factory=list)
class AgentComposerCandidatesResponse(ResponseModel):
@@ -0,0 +1,135 @@
"""add agent config drafts
Revision ID: e4f5a6b7c8d9
Revises: d9e8f7a6b5c4
Create Date: 2026-06-24 20:15:00.000000
"""
from datetime import UTC, datetime
import sqlalchemy as sa
from alembic import op
import models
from libs.uuid_utils import uuidv7
# revision identifiers, used by Alembic.
revision = "e4f5a6b7c8d9"
down_revision = "d9e8f7a6b5c4"
branch_labels = None
depends_on = None
def _is_pg(conn) -> bool:
return conn.dialect.name == "postgresql"
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
kwargs = {"nullable": nullable, "primary_key": primary_key}
if primary_key and _is_pg(op.get_bind()):
kwargs["server_default"] = sa.text("uuidv7()")
return sa.Column(name, models.types.StringUUID(), **kwargs)
def upgrade():
op.create_table(
"agent_config_drafts",
_uuid_column("id", primary_key=True),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
sa.Column("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), server_default="", 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"],
)
bind = op.get_bind()
now = datetime.now(UTC).replace(tzinfo=None)
if bind.dialect.name == "postgresql":
op.execute(
sa.text(
"""
INSERT INTO agent_config_drafts (
id, tenant_id, agent_id, draft_type, account_id, draft_owner_key, base_snapshot_id,
config_snapshot, created_by, updated_by, created_at, updated_at
)
SELECT
uuidv7(), a.tenant_id, a.id, 'draft', NULL, '', s.id,
s.config_snapshot, a.created_by, a.updated_by, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM agents a
JOIN agent_config_snapshots s
ON s.tenant_id = a.tenant_id
AND s.agent_id = a.id
AND s.id = a.active_config_snapshot_id
WHERE a.active_config_snapshot_id IS NOT NULL
"""
)
)
else:
agents = bind.execute(
sa.text(
"""
SELECT
a.tenant_id, a.id AS agent_id, a.created_by, a.updated_by,
s.id AS snapshot_id, s.config_snapshot
FROM agents a
JOIN agent_config_snapshots s
ON s.tenant_id = a.tenant_id
AND s.agent_id = a.id
AND s.id = a.active_config_snapshot_id
WHERE a.active_config_snapshot_id IS NOT NULL
"""
)
).mappings()
for row in agents:
bind.execute(
sa.text(
"""
INSERT INTO agent_config_drafts (
id, tenant_id, agent_id, draft_type, account_id, draft_owner_key, base_snapshot_id,
config_snapshot, created_by, updated_by, created_at, updated_at
)
VALUES (
:id, :tenant_id, :agent_id, 'draft', NULL, '', :snapshot_id,
:config_snapshot, :created_by, :updated_by, :created_at, :updated_at
)
"""
),
{
"id": str(uuidv7()),
"tenant_id": row["tenant_id"],
"agent_id": row["agent_id"],
"snapshot_id": row["snapshot_id"],
"config_snapshot": row["config_snapshot"],
"created_by": row["created_by"],
"updated_by": row["updated_by"],
"created_at": now,
"updated_at": now,
},
)
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")
+4
View File
@@ -10,6 +10,8 @@ from .account import (
)
from .agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@@ -154,6 +156,8 @@ __all__ = [
"AccountStatus",
"AccountTrialAppRecord",
"Agent",
"AgentConfigDraft",
"AgentConfigDraftType",
"AgentConfigRevision",
"AgentConfigRevisionOperation",
"AgentConfigSnapshot",
+54 -3
View File
@@ -85,6 +85,17 @@ class AgentConfigRevisionOperation(StrEnum):
SAVE_TO_ROSTER = "save_to_roster"
# Switches the Agent's current published config back to an existing version.
RESTORE_VERSION = "restore_version"
# Publishes the editable Agent Soul draft as a new immutable version.
PUBLISH_DRAFT = "publish_draft"
class AgentConfigDraftType(StrEnum):
"""Editable Agent Soul draft workspace type."""
# Shared Agent Console draft edited by users before publishing.
DRAFT = "draft"
# Per-editor build draft mutated during debug/build mode.
DEBUG_BUILD = "debug_build"
class WorkflowAgentBindingType(StrEnum):
@@ -210,6 +221,46 @@ 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 +406,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.
+189 -8
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
import re
from enum import StrEnum
from typing import Annotated, Any, Final, Literal
from typing import Annotated, Any, Final, Literal, Self
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator
from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator
from core.workflow.file_reference import is_canonical_file_reference
from graphon.file import FileTransferMethod
@@ -159,6 +160,22 @@ class AgentSkillRefConfig(AgentFlexibleConfig):
# Zip member path listing from standardization (ENG-371): lets infer-tools
# show the model strong signals like ``scripts/*.sh`` without unpacking.
manifest_files: list[str] | None = None
# Versioned drive KV entries that belong to this skill package. The drive
# table remains the storage index, but Agent Soul owns which concrete file
# ids are visible for a snapshot/version.
file_refs: list[AgentFileRefConfig] = Field(default_factory=list)
class AgentSoulFilesConfig(BaseModel):
"""Versioned Agent Soul references to drive-backed skills and files.
File bytes and drive value pointers stay in ``agent_drive_files``. This
section records which drive keys belong to one Agent Soul snapshot so version
restore/copy/runtime use the same skills/files view the user published.
"""
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
files: list[AgentFileRefConfig] = Field(default_factory=list)
class AgentPermissionConfig(BaseModel):
@@ -236,17 +253,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 +614,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):
@@ -515,6 +695,7 @@ class AgentSoulConfig(BaseModel):
env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig)
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
model: AgentSoulModelConfig | None = None
app_features: AgentSoulAppFeaturesConfig = Field(default_factory=AgentSoulAppFeaturesConfig)
app_variables: list[AppVariableConfig] = Field(default_factory=list)
+95 -8
View File
@@ -12453,6 +12453,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 +12487,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
@@ -12862,14 +12881,44 @@ the current roster/workflow APIs scoped to Dify Agent.
| id | string | | No |
| name | string | | No |
#### AgentKnowledgeMetadataCondition
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| 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
| 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
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| query | string | | No |
| score_threshold | number | | No |
| score_threshold_enabled | boolean | | No |
| top_k | integer | | No |
| mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | Yes |
| value | string | | No |
#### AgentKnowledgeQueryMode
@@ -12877,6 +12926,46 @@ the current roster/workflow APIs scoped to Dify Agent.
| ---- | ---- | ----------- | -------- |
| AgentKnowledgeQueryMode | string | | |
#### AgentKnowledgeRerankingModelConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| model | string | | Yes |
| provider | string | | Yes |
#### AgentKnowledgeRetrievalConfig
| 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
| 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 |
@@ -13278,9 +13367,7 @@ old Agent tool payloads can be read while new payloads stay explicit.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
| query_config | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | No |
| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No |
| sets | [ [AgentKnowledgeSetConfig](#agentknowledgesetconfig) ] | | No |
#### AgentSoulMemoryConfig
+32 -12
View File
@@ -25,6 +25,7 @@ from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
)
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
MAX_CANDIDATES_PER_LIST = 200
@@ -139,30 +140,49 @@ 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,
}
)
human_contacts = [contact.model_dump(exclude_none=True) for contact in soul.human.contacts]
skills = [skill.model_dump(exclude_none=True) for skill in soul.files.skills]
files = [file_ref.model_dump(exclude_none=True) for file_ref in soul.files.files]
dify_tools = workspace_tools_loader()
lists = {
"dify_tools": dify_tools,
"cli_tools": cli_tools,
"knowledge_datasets": knowledge_datasets,
"knowledge_sets": knowledge_sets,
"human_contacts": human_contacts,
"skills": skills,
"files": files,
}
capped: dict[str, list[dict[str, Any]]] = {}
for key, values in lists.items():
+486 -123
View File
@@ -11,6 +11,8 @@ from libs.helper import to_timestamp
from models import Account
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@@ -37,7 +39,12 @@ 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.agent.soul_files_service import AgentSoulFilesService
from services.app_service import AppService, CreateAppParams
from services.entities.agent_entities import (
AgentSoulConfig,
@@ -120,6 +127,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,27 +267,23 @@ 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)
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,
"draft": cls._serialize_draft(draft),
"agent_soul": draft.config_snapshot_dict,
"save_options": [
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value,
@@ -294,20 +298,11 @@ class AgentComposerService:
raise ValueError("Agent App composer endpoint only accepts agent_app variant")
_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)
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
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,
@@ -327,32 +322,20 @@ class AgentComposerService:
except IntegrityError as exc:
db.session.rollback()
raise AgentNameConflictError() from exc
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,
)
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
payload.agent_soul = cls._preserve_agent_draft_soul_files(
tenant_id=tenant_id,
agent_id=agent.id,
agent_soul=payload.agent_soul,
)
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)
@@ -363,6 +346,167 @@ 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)
if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP:
raise AgentNotFoundError()
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)
payload.agent_soul = cls._preserve_agent_draft_soul_files(
tenant_id=tenant_id,
agent_id=agent.id,
agent_soul=payload.agent_soul,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id=account_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,29 +516,43 @@ 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(
tenant_id=tenant_id,
agent_id=agent_id,
prompt=payload.agent_soul.prompt.system_prompt,
agent_soul=payload.agent_soul,
)
)
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)."""
@@ -426,14 +584,16 @@ class AgentComposerService:
*,
tenant_id: str,
agent_id: str,
prompt: str,
agent_soul: AgentSoulConfig,
) -> list[dict[str, str | None]]:
"""Soft warnings for missing drive-backed prompt mentions."""
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
from services.agent_drive_service import decode_drive_mention_ref
soul_skill_keys = {skill.skill_md_key for skill in agent_soul.files.skills if skill.skill_md_key}
soul_file_keys = {file_ref.drive_key for file_ref in agent_soul.files.files if file_ref.drive_key}
wanted_keys: dict[str, tuple[str, str]] = {}
for mention in parse_prompt_mentions(prompt):
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
continue
decoded_key = decode_drive_mention_ref(mention.ref_id)
@@ -454,6 +614,28 @@ class AgentComposerService:
)
findings: list[dict[str, str | None]] = []
for key, (kind, display) in wanted_keys.items():
if kind == MentionKind.SKILL.value and key not in soul_skill_keys:
findings.append(
{
"code": "mention_target_missing",
"surface": "agent_soul",
"kind": kind,
"id": key,
"message": f"{kind} '{display}' is not recorded in this Agent Soul version.",
}
)
continue
if kind == MentionKind.FILE.value and key not in soul_file_keys:
findings.append(
{
"code": "mention_target_missing",
"surface": "agent_soul",
"kind": kind,
"id": key,
"message": f"{kind} '{display}' is not recorded in this Agent Soul version.",
}
)
continue
if key in existing_keys:
continue
findings.append(
@@ -509,7 +691,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
@@ -536,7 +718,7 @@ class AgentComposerService:
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_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(
@@ -569,23 +751,17 @@ class AgentComposerService:
@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)
)
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_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 +805,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.
@@ -763,6 +915,11 @@ class AgentComposerService:
)
binding.node_job_config = node_job
if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
payload.agent_soul = cls._preserve_active_soul_files(
tenant_id=tenant_id,
agent_id=binding.agent_id,
agent_soul=payload.agent_soul,
)
current_snapshot = cls._require_version(
tenant_id=tenant_id,
agent_id=binding.agent_id,
@@ -863,6 +1020,11 @@ class AgentComposerService:
binding = cls._require_binding(binding)
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
payload.agent_soul = cls._preserve_active_soul_files(
tenant_id=tenant_id,
agent_id=binding.agent_id,
agent_soul=payload.agent_soul,
)
current_snapshot = cls._require_version(
tenant_id=tenant_id,
agent_id=binding.agent_id,
@@ -897,6 +1059,11 @@ class AgentComposerService:
binding = cls._require_binding(binding)
if not binding.agent_id or payload.agent_soul is None:
raise ValueError("agent_id and agent_soul are required")
payload.agent_soul = cls._preserve_active_soul_files(
tenant_id=tenant_id,
agent_id=binding.agent_id,
agent_soul=payload.agent_soul,
)
version = cls._create_config_version(
tenant_id=tenant_id,
agent_id=binding.agent_id,
@@ -929,6 +1096,12 @@ class AgentComposerService:
) -> WorkflowAgentNodeBinding:
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
if binding and binding.agent_id:
payload.agent_soul = cls._preserve_active_soul_files(
tenant_id=tenant_id,
agent_id=binding.agent_id,
agent_soul=payload.agent_soul,
)
agent_name = payload.new_agent_name or "Untitled Agent"
agent = cls._create_roster_agent_for_composer(
tenant_id=tenant_id,
@@ -944,6 +1117,15 @@ class AgentComposerService:
version_note=payload.version_note,
)
node_job = payload.node_job or WorkflowNodeJobConfig()
if binding and binding.agent_id:
cls._copy_agent_drive_rows(
tenant_id=tenant_id,
source_agent_id=binding.agent_id,
target_agent_id=agent.id,
account_id=account_id,
agent_soul=payload.agent_soul,
node_job=node_job,
)
if not binding:
binding = WorkflowAgentNodeBinding(
tenant_id=tenant_id,
@@ -979,6 +1161,9 @@ class AgentComposerService:
version_id=binding.current_snapshot_id,
)
agent_soul = payload.agent_soul or AgentSoulConfig.model_validate(source_version.config_snapshot_dict)
source_soul = AgentSoulConfig.model_validate(source_version.config_snapshot_dict)
agent_soul = agent_soul.model_copy(deep=True)
agent_soul.files = source_soul.files
agent_name = payload.new_agent_name or source_agent.name
roster_agent = cls._create_roster_agent_for_composer(
tenant_id=tenant_id,
@@ -1120,26 +1305,63 @@ class AgentComposerService:
)
)
@classmethod
def _preserve_active_soul_files(
cls,
*,
tenant_id: str,
agent_id: str | None,
agent_soul: AgentSoulConfig,
) -> AgentSoulConfig:
"""Keep drive refs owned by drive APIs when saving non-file composer changes."""
if not agent_id:
return agent_soul
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=agent_id)
if agent is None or not agent.active_config_snapshot_id:
return agent_soul
version = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id,
version_id=agent.active_config_snapshot_id,
)
if version is None:
return agent_soul
existing_soul = AgentSoulConfig.model_validate(version.config_snapshot_dict)
preserved = agent_soul.model_copy(deep=True)
preserved.files = existing_soul.files
return preserved
@classmethod
def _preserve_agent_draft_soul_files(
cls,
*,
tenant_id: str,
agent_id: str | None,
agent_soul: AgentSoulConfig,
draft_type: AgentConfigDraftType = AgentConfigDraftType.DRAFT,
account_id: str | None = None,
) -> AgentSoulConfig:
if not agent_id:
return agent_soul
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:
existing_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
preserved = agent_soul.model_copy(deep=True)
preserved.files = existing_soul.files
return preserved
return cls._preserve_active_soul_files(tenant_id=tenant_id, agent_id=agent_id, agent_soul=agent_soul)
@staticmethod
def _drive_copy_scopes_from_agent_configs(
*, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None
) -> tuple[set[str], set[str]]:
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
from services.agent_drive_service import decode_drive_mention_ref
exact_keys: set[str] = set()
prefixes: set[str] = set()
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
continue
drive_key = decode_drive_mention_ref(mention.ref_id)
if not drive_key:
continue
if mention.kind == MentionKind.SKILL and "/" in drive_key:
prefixes.add(f"{drive_key.rsplit('/', 1)[0]}/")
else:
exact_keys.add(drive_key)
exact_keys, prefixes = AgentSoulFilesService.drive_copy_scopes(agent_soul=agent_soul)
if node_job is not None:
for file_ref in node_job.metadata.file_refs or []:
@@ -1285,6 +1507,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(
@@ -1479,6 +1838,10 @@ 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,
"status": agent.status.value,
"active_config_snapshot_id": agent.active_config_snapshot_id,
+5 -5
View File
@@ -148,15 +148,15 @@ class ComposerConfigValidator:
cls,
payload: ComposerSavePayload,
*,
existing_dataset_ids: set[str] | None = None,
existing_knowledge_set_ids: set[str] | None = None,
) -> dict[str, Any]:
"""ENG-617 §5.3/§5.4 soft findings — never block save.
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge-set
mentions with a placeholder name (0522 consensus) instead of dropping or
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
deleted datasets surface as placeholders too.
rejecting them. With ``existing_knowledge_set_ids`` provided, mentions
that no longer exist in the current Agent Soul surface as placeholders too.
"""
warnings: list[dict[str, Any]] = []
placeholders: list[dict[str, str]] = []
@@ -188,7 +188,7 @@ class ComposerConfigValidator:
resolved = resolver(mention)
if mention.kind == MentionKind.KNOWLEDGE:
dangling = resolved is None or (
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
existing_knowledge_set_ids is not None and mention.ref_id not in existing_knowledge_set_ids
)
if dangling:
placeholders.append(
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import Any
from uuid import UUID
from models.agent_config_entities import AgentSoulConfig
def list_agent_soul_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
"""Return normalized unique knowledge dataset ids in config order.
Agent v2 knowledge dataset selection is owned by ``knowledge.sets``. This
helper keeps composer, workflow validation, candidates, and runtime
diagnostics aligned on the same normalization rules: strip whitespace, drop
blanks, preserve first-seen order, and deduplicate.
"""
dataset_ids: list[str] = []
seen: set[str] = set()
for knowledge_set in agent_soul.knowledge.sets:
for dataset in knowledge_set.datasets:
dataset_id = (dataset.id or "").strip()
if not dataset_id or dataset_id in seen:
continue
seen.add(dataset_id)
dataset_ids.append(dataset_id)
return dataset_ids
def get_tenant_knowledge_dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
"""Return tenant-scoped dataset rows for normalized knowledge dataset ids.
Knowledge ids come from user-editable config. Malformed ids can never match
a dataset row, so they are treated as missing instead of breaking the
UUID-typed dataset lookup.
"""
from services.dataset_service import DatasetService
valid_ids: list[str] = []
for dataset_id in dataset_ids:
try:
UUID(dataset_id)
except (TypeError, ValueError):
continue
valid_ids.append(dataset_id)
if not valid_ids:
return {}
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
return {str(row.id): row for row in rows}
def list_missing_tenant_knowledge_dataset_ids(*, tenant_id: str, agent_soul: AgentSoulConfig | None) -> list[str]:
"""Return normalized knowledge dataset ids missing from the tenant scope."""
if agent_soul is None:
return []
dataset_ids = list_agent_soul_knowledge_dataset_ids(agent_soul)
if not dataset_ids:
return []
rows = get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=dataset_ids)
return [dataset_id for dataset_id in dataset_ids if dataset_id not in rows]
+4 -4
View File
@@ -6,7 +6,7 @@ Slash-menu insertions are stored inline in the plain-string prompt as tokens:
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent
runtime context. For prompt-owned entities that means Agent Soul lists such as
``tools`` / ``knowledge.datasets`` / ``human.contacts`` and workflow job lists
``tools`` / ``knowledge.sets`` / ``human.contacts`` and workflow job lists
such as ``previous_node_output_refs`` / ``declared_outputs``. For drive-backed
``skill`` / ``file`` mentions the field stores a URL-encoded drive key and is
resolved against ``agent_drive_files`` at runtime. ``label`` is an optional
@@ -211,9 +211,9 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
if mention.ref_id in (cli_tool.id, cli_tool.name):
return cli_tool.name or cli_tool.id
case MentionKind.KNOWLEDGE:
for dataset in agent_soul.knowledge.datasets:
if mention.ref_id == dataset.id:
return dataset.name or dataset.id
for knowledge_set in agent_soul.knowledge.sets:
if mention.ref_id == knowledge_set.id:
return knowledge_set.name or knowledge_set.id
case MentionKind.HUMAN:
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
case _:
+54 -19
View File
@@ -8,6 +8,8 @@ from libs.datetime_utils import naive_utc_now
from libs.helper import to_timestamp
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@@ -574,7 +576,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."""
@@ -836,6 +838,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 +852,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 +981,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)
@@ -50,6 +50,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,
@@ -109,7 +110,7 @@ class SkillStandardizeService:
)
)
self._drive.commit(
committed_items = self._drive.commit(
tenant_id=tenant_id,
user_id=user_id,
agent_id=agent_id,
@@ -133,6 +134,7 @@ class SkillStandardizeService:
*member_items,
],
)
self.last_committed_items = committed_items
drive_skill = next(
skill
+528
View File
@@ -0,0 +1,528 @@
from __future__ import annotations
import json
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from extensions.ext_database import db
from models.agent import (
Agent,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentDriveFile,
)
from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig, AgentSoulConfig
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent_drive_service import AgentDriveError, DriveSkillMetadata, normalize_drive_key
_SKILL_MD_SUFFIX = "/SKILL.md"
_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
_FILES_PREFIX = "files/"
class AgentSoulFilesService:
"""Versioned Agent Soul view of drive-backed skills and files.
``agent_drive_files`` remains the storage/index for bytes and drive values.
``AgentSoulConfig.files`` records the versioned pointers that a specific
Agent Soul snapshot owns, so restore/publish/runtime do not accidentally see
later drive mutations.
"""
@classmethod
def sync_drive_commit_to_active_soul(
cls,
*,
tenant_id: str,
agent_id: str,
account_id: str,
committed_items: list[dict[str, Any]],
) -> AgentConfigSnapshot | None:
if not committed_items:
return None
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id))
if agent is None or not agent.active_config_snapshot_id:
return None
current_snapshot = db.session.scalar(
select(AgentConfigSnapshot).where(
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent_id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
)
if current_snapshot is None:
return None
agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict).model_copy(deep=True)
before = agent_soul.files.model_dump(mode="json")
for item in committed_items:
cls._apply_commit_item(agent_soul=agent_soul, item=item)
if agent_soul.files.model_dump(mode="json") == before:
return None
version = cls._create_config_version(
tenant_id=tenant_id,
agent_id=agent_id,
account_id=account_id,
agent_soul=agent_soul,
previous_snapshot_id=current_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
db.session.flush()
return version
@classmethod
def list_files(
cls,
*,
session: Session,
tenant_id: str,
agent_id: str,
prefix: str = "",
) -> list[dict[str, Any]]:
agent_soul = cls.active_agent_soul(session=session, tenant_id=tenant_id, agent_id=agent_id)
file_keys = [file.drive_key for file in agent_soul.files.files if file.drive_key]
if prefix:
normalized_prefix = normalize_drive_key(prefix)
file_keys = [key for key in file_keys if key.startswith(normalized_prefix)]
if not file_keys:
return []
rows = cls._drive_rows_by_key(session=session, tenant_id=tenant_id, agent_id=agent_id, keys=file_keys)
items: list[dict[str, Any]] = []
for file_ref in agent_soul.files.files:
key = file_ref.drive_key
if not key or key not in file_keys:
continue
row = rows.get(key)
item = cls._file_item_from_ref(file_ref)
item.update(cls._row_item(row) if row is not None else {"key": key, "missing": True})
items.append(item)
return items
@classmethod
def list_manifest_items(
cls,
*,
session: Session,
tenant_id: str,
agent_id: str,
prefix: str = "",
) -> list[dict[str, Any]]:
agent_soul = cls.active_agent_soul(session=session, tenant_id=tenant_id, agent_id=agent_id)
refs = cls._all_file_refs(agent_soul)
if prefix:
normalized_prefix = normalize_drive_key(prefix)
refs = [ref for ref in refs if (ref.drive_key or "").startswith(normalized_prefix)]
keys = [ref.drive_key for ref in refs if ref.drive_key]
rows = cls._drive_rows_by_key(session=session, tenant_id=tenant_id, agent_id=agent_id, keys=keys)
items: list[dict[str, Any]] = []
for file_ref in refs:
key = file_ref.drive_key
if not key:
continue
row = rows.get(key)
item = cls._file_item_from_ref(file_ref)
item.update(cls._row_item(row) if row is not None else {"key": key, "missing": True})
item["file_id"] = file_ref.file_id or file_ref.upload_file_id
items.append(item)
return sorted(items, key=lambda item: str(item.get("key") or ""))
@classmethod
def list_skills(
cls,
*,
session: Session,
tenant_id: str,
agent_id: str,
) -> list[dict[str, Any]]:
agent_soul = cls.active_agent_soul(session=session, tenant_id=tenant_id, agent_id=agent_id)
skill_keys = [skill.skill_md_key for skill in agent_soul.files.skills if skill.skill_md_key]
archive_keys = [skill.full_archive_key for skill in agent_soul.files.skills if skill.full_archive_key]
rows = cls._drive_rows_by_key(
session=session, tenant_id=tenant_id, agent_id=agent_id, keys=[*skill_keys, *archive_keys]
)
items: list[dict[str, Any]] = []
for skill in agent_soul.files.skills:
if not skill.skill_md_key:
continue
row = rows.get(skill.skill_md_key)
archive_key = skill.full_archive_key if skill.full_archive_key in rows else None
skill_md_ref = cls.file_ref_for_key(agent_soul=agent_soul, key=skill.skill_md_key)
items.append(
{
"path": skill.path or cls.skill_path_from_key(skill.skill_md_key),
"skill_md_key": skill.skill_md_key,
"archive_key": archive_key or skill.full_archive_key,
"name": skill.name,
"description": skill.description,
"size": row.size if row is not None else None,
"mime_type": row.mime_type if row is not None else (skill_md_ref.type if skill_md_ref else None),
"hash": row.hash if row is not None else None,
"created_at": int(row.created_at.timestamp()) if row is not None and row.created_at else None,
"missing": row is None,
}
)
return items
@classmethod
def allowed_drive_keys(cls, agent_soul: AgentSoulConfig) -> set[str]:
keys: set[str] = set()
for file_ref in agent_soul.files.files:
if file_ref.drive_key:
keys.add(file_ref.drive_key)
for skill in agent_soul.files.skills:
if skill.skill_md_key:
keys.add(skill.skill_md_key)
if skill.full_archive_key:
keys.add(skill.full_archive_key)
for file_ref in skill.file_refs:
if file_ref.drive_key:
keys.add(file_ref.drive_key)
return keys
@classmethod
def allowed_skill_prefixes(cls, agent_soul: AgentSoulConfig) -> set[str]:
prefixes: set[str] = set()
for skill in agent_soul.files.skills:
path = skill.path or (cls.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else None)
if path:
prefixes.add(f"{path}/")
return prefixes
@classmethod
def key_allowed_by_soul(cls, *, agent_soul: AgentSoulConfig, key: str) -> bool:
normalized_key = normalize_drive_key(key)
if normalized_key in cls.allowed_drive_keys(agent_soul):
return True
return any(normalized_key.startswith(prefix) for prefix in cls.allowed_skill_prefixes(agent_soul))
@classmethod
def file_ref_for_key(cls, *, agent_soul: AgentSoulConfig, key: str) -> AgentFileRefConfig | None:
normalized_key = normalize_drive_key(key)
for file_ref in agent_soul.files.files:
if file_ref.drive_key == normalized_key:
return file_ref
for skill in agent_soul.files.skills:
for file_ref in skill.file_refs:
if file_ref.drive_key == normalized_key:
return file_ref
return None
@classmethod
def drive_copy_scopes(cls, *, agent_soul: AgentSoulConfig) -> tuple[set[str], set[str]]:
exact_keys = cls.allowed_drive_keys(agent_soul)
prefixes = cls.allowed_skill_prefixes(agent_soul)
return exact_keys, prefixes
@staticmethod
def active_agent_soul(*, session: Session, tenant_id: str, agent_id: str) -> AgentSoulConfig:
snapshot = session.scalar(
select(AgentConfigSnapshot)
.join(Agent, Agent.active_config_snapshot_id == AgentConfigSnapshot.id)
.where(
Agent.tenant_id == tenant_id,
Agent.id == agent_id,
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent_id,
)
)
if snapshot is None:
raise AgentDriveError(
"agent_snapshot_not_found",
"agent has no active Agent Soul snapshot",
status_code=404,
)
return AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
@staticmethod
def skill_path_from_key(key: str) -> str:
if not key.endswith(_SKILL_MD_SUFFIX):
raise AgentDriveError(
"invalid_skill_key",
"skill rows must use the canonical '<path>/SKILL.md' key",
status_code=500,
)
return key[: -len(_SKILL_MD_SUFFIX)]
@staticmethod
def skill_archive_key(skill_md_key: str) -> str:
return f"{AgentSoulFilesService.skill_path_from_key(skill_md_key)}/{_SKILL_ARCHIVE_NAME}"
@classmethod
def _apply_commit_item(cls, *, agent_soul: AgentSoulConfig, item: dict[str, Any]) -> None:
key = normalize_drive_key(str(item.get("key") or ""))
if item.get("removed"):
cls._remove_ref(agent_soul=agent_soul, key=key)
return
if item.get("is_skill"):
cls._upsert_skill_ref(agent_soul=agent_soul, key=key, item=item)
return
if key.startswith(_FILES_PREFIX):
cls._upsert_file_ref(agent_soul=agent_soul, key=key, item=item)
return
cls._upsert_skill_file_ref(agent_soul=agent_soul, key=key, item=item)
@classmethod
def _upsert_skill_ref(cls, *, agent_soul: AgentSoulConfig, key: str, item: dict[str, Any]) -> None:
metadata = cls._parse_skill_metadata(item.get("skill_metadata"))
path = cls.skill_path_from_key(key)
ref = AgentSkillRefConfig(
id=path,
name=metadata.name,
description=metadata.description,
file_id=str(item.get("file_id") or ""),
path=path,
skill_md_key=key,
skill_md_file_id=str(item.get("file_id") or ""),
full_archive_key=cls.skill_archive_key(key),
manifest_files=metadata.manifest_files,
)
existing_ref = next(
(
existing
for existing in agent_soul.files.skills
if existing.skill_md_key == key or existing.path == path
),
None,
)
file_refs = list(existing_ref.file_refs) if existing_ref else []
file_refs = [file_ref for file_ref in file_refs if file_ref.drive_key != key]
file_refs.append(cls._file_ref_from_item(key=key, item=item, name="SKILL.md"))
archive_key = cls.skill_archive_key(key)
archive_ref = next((file_ref for file_ref in file_refs if file_ref.drive_key == archive_key), None)
if archive_ref:
ref.full_archive_file_id = archive_ref.file_id
ref.file_refs = sorted(file_refs, key=lambda value: value.drive_key or value.name)
skills = [
existing
for existing in agent_soul.files.skills
if existing.skill_md_key != key and existing.path != path
]
skills.append(ref)
skills.sort(key=lambda value: value.path or value.skill_md_key or "")
agent_soul.files.skills = skills
@staticmethod
def _upsert_file_ref(*, agent_soul: AgentSoulConfig, key: str, item: dict[str, Any]) -> None:
name = key.removeprefix(_FILES_PREFIX) or key.rsplit("/", 1)[-1]
file_id = str(item.get("file_id") or "")
ref = AgentFileRefConfig(
id=key,
file_id=file_id,
upload_file_id=file_id if item.get("file_kind") == "upload_file" else None,
name=name,
type=str(item.get("mime_type") or ""),
transfer_method=str(item.get("file_kind") or ""),
drive_key=key,
size=item.get("size"),
hash=item.get("hash"),
)
files = [existing for existing in agent_soul.files.files if existing.drive_key != key]
files.append(ref)
files.sort(key=lambda value: value.drive_key or value.name)
agent_soul.files.files = files
@classmethod
def _upsert_skill_file_ref(cls, *, agent_soul: AgentSoulConfig, key: str, item: dict[str, Any]) -> None:
path = key.split("/", 1)[0]
if not path:
return
updated: list[AgentSkillRefConfig] = []
changed = False
for skill in agent_soul.files.skills:
skill_path = skill.path or (cls.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else "")
if skill_path != path:
updated.append(skill)
continue
file_ref = cls._file_ref_from_item(key=key, item=item)
file_refs = [existing for existing in skill.file_refs if existing.drive_key != key]
file_refs.append(file_ref)
replacement = skill.model_copy(
update={"file_refs": sorted(file_refs, key=lambda value: value.drive_key or value.name)}
)
if key.endswith(f"/{_SKILL_ARCHIVE_NAME}"):
replacement = replacement.model_copy(
update={
"full_archive_key": key,
"full_archive_file_id": file_ref.file_id,
}
)
updated.append(replacement)
changed = True
if changed:
agent_soul.files.skills = updated
@staticmethod
def _file_ref_from_item(*, key: str, item: dict[str, Any], name: str | None = None) -> AgentFileRefConfig:
file_id = str(item.get("file_id") or "")
return AgentFileRefConfig(
id=key,
file_id=file_id,
upload_file_id=file_id if item.get("file_kind") == "upload_file" else None,
name=name or key.rsplit("/", 1)[-1],
type=str(item.get("mime_type") or ""),
transfer_method=str(item.get("file_kind") or ""),
drive_key=key,
size=item.get("size"),
hash=item.get("hash"),
)
@classmethod
def _remove_ref(cls, *, agent_soul: AgentSoulConfig, key: str) -> None:
agent_soul.files.files = [file_ref for file_ref in agent_soul.files.files if file_ref.drive_key != key]
if key.endswith(_SKILL_MD_SUFFIX):
path = cls.skill_path_from_key(key)
agent_soul.files.skills = [
skill for skill in agent_soul.files.skills if skill.skill_md_key != key and skill.path != path
]
return
if key.endswith(f"/{_SKILL_ARCHIVE_NAME}"):
agent_soul.files.skills = [
skill.model_copy(
update={
"full_archive_key": None,
"full_archive_file_id": None,
"file_refs": [file_ref for file_ref in skill.file_refs if file_ref.drive_key != key],
}
)
if skill.full_archive_key == key
else skill
for skill in agent_soul.files.skills
]
return
path = key.split("/", 1)[0]
if path:
agent_soul.files.skills = [
skill.model_copy(
update={"file_refs": [file_ref for file_ref in skill.file_refs if file_ref.drive_key != key]}
)
if (skill.path or (cls.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else "")) == path
else skill
for skill in agent_soul.files.skills
]
@staticmethod
def _all_file_refs(agent_soul: AgentSoulConfig) -> list[AgentFileRefConfig]:
refs = list(agent_soul.files.files)
for skill in agent_soul.files.skills:
refs.extend(skill.file_refs)
return refs
@staticmethod
def _parse_skill_metadata(raw_metadata: Any) -> DriveSkillMetadata:
if isinstance(raw_metadata, DriveSkillMetadata):
return raw_metadata
if isinstance(raw_metadata, str):
return DriveSkillMetadata.model_validate(json.loads(raw_metadata))
return DriveSkillMetadata.model_validate(raw_metadata or {})
@staticmethod
def _drive_rows_by_key(
*,
session: Session,
tenant_id: str,
agent_id: str,
keys: list[str],
) -> dict[str, AgentDriveFile]:
if not keys:
return {}
return {
row.key: row
for row in session.scalars(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key.in_(sorted(set(keys))),
)
)
}
@staticmethod
def _row_item(row: AgentDriveFile | None) -> dict[str, Any]:
if row is None:
return {}
return {
"key": row.key,
"size": row.size,
"hash": row.hash,
"mime_type": row.mime_type,
"file_kind": row.file_kind.value,
"is_skill": row.is_skill,
"skill_metadata": row.skill_metadata,
"created_at": int(row.created_at.timestamp()) if row.created_at else None,
}
@staticmethod
def _file_item_from_ref(file_ref: AgentFileRefConfig) -> dict[str, Any]:
key = file_ref.drive_key or file_ref.name
return {
"key": key,
"name": file_ref.name,
"mime_type": file_ref.type,
"file_kind": file_ref.transfer_method,
"is_skill": False,
"size": file_ref.get("size"),
"hash": file_ref.get("hash"),
}
@classmethod
def _create_config_version(
cls,
*,
tenant_id: str,
agent_id: str,
account_id: str,
agent_soul: AgentSoulConfig,
previous_snapshot_id: str,
) -> AgentConfigSnapshot:
next_version = (
db.session.scalar(
select(func.max(AgentConfigSnapshot.version)).where(
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent_id,
)
)
or 0
) + 1
version = AgentConfigSnapshot(
tenant_id=tenant_id,
agent_id=agent_id,
version=next_version,
config_snapshot=agent_soul,
created_by=account_id,
)
db.session.add(version)
db.session.flush()
revision = AgentConfigRevision(
tenant_id=tenant_id,
agent_id=agent_id,
previous_snapshot_id=previous_snapshot_id,
current_snapshot_id=version.id,
revision=cls._next_revision(tenant_id=tenant_id, agent_id=agent_id),
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
created_by=account_id,
)
db.session.add(revision)
db.session.flush()
return version
@staticmethod
def _next_revision(*, tenant_id: str, agent_id: str) -> int:
return (
db.session.scalar(
select(func.max(AgentConfigRevision.revision)).where(
AgentConfigRevision.tenant_id == tenant_id,
AgentConfigRevision.agent_id == agent_id,
)
)
or 0
) + 1
+72 -13
View File
@@ -9,10 +9,11 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator
from models import ToolFile, UploadFile
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentDriveFile,
AgentDriveFileKind,
AgentScope,
AgentStatus,
WorkflowAgentBindingType,
@@ -21,6 +22,7 @@ from models.agent import (
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig
from models.workflow import Workflow
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.soul_files_service import AgentSoulFilesService
from services.entities.agent_entities import (
ComposerSavePayload,
ComposerSaveStrategy,
@@ -39,10 +41,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 +66,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
@@ -169,6 +183,8 @@ class WorkflowAgentPublishService:
from services.agent_drive_service import decode_drive_mention_ref
wanted_keys: dict[str, tuple[str, str]] = {}
soul_skill_keys = {skill.skill_md_key for skill in agent_soul.files.skills if skill.skill_md_key}
soul_file_keys = AgentSoulFilesService.allowed_drive_keys(agent_soul=agent_soul) - soul_skill_keys
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
continue
@@ -177,29 +193,67 @@ class WorkflowAgentPublishService:
continue
code = "skill_ref_dangling" if mention.kind == MentionKind.SKILL else "file_ref_dangling"
wanted_keys[drive_key] = (code, mention.label or drive_key)
if not wanted_keys or not binding.agent_id:
if not binding.agent_id:
return
existing_keys = set(
session.scalars(
select(AgentDriveFile.key).where(
AgentDriveFile.tenant_id == binding.tenant_id,
AgentDriveFile.agent_id == binding.agent_id,
AgentDriveFile.key.in_(sorted(wanted_keys)),
)
).all()
)
declared_keys, _ = AgentSoulFilesService.drive_copy_scopes(agent_soul=agent_soul)
check_keys = sorted(set(wanted_keys) | declared_keys)
if not check_keys:
return
existing_keys = {
key
for key in check_keys
if cls._soul_file_ref_exists(session=session, tenant_id=binding.tenant_id, agent_soul=agent_soul, key=key)
}
messages: list[str] = []
for key, (code, display) in wanted_keys.items():
if code == "skill_ref_dangling" and key not in soul_skill_keys:
messages.append(f"{code}: skill '{display}' is not recorded in this Agent Soul version.")
continue
if code == "file_ref_dangling" and key not in soul_file_keys:
messages.append(f"{code}: file '{display}' is not recorded in this Agent Soul version.")
continue
if key in existing_keys:
continue
kind = "skill" if code == "skill_ref_dangling" else "file"
messages.append(f"{code}: {kind} '{display}' has no drive entry for key '{key}'.")
for key in declared_keys:
if key not in existing_keys:
messages.append(f"drive_ref_dangling: Agent Soul drive ref '{key}' has no backing drive entry.")
if messages:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has invalid Agent Soul drive refs: {'; '.join(messages)}"
)
@staticmethod
def _soul_file_ref_exists(
*,
session: Session,
tenant_id: str,
agent_soul: AgentSoulConfig,
key: str,
) -> bool:
file_ref = AgentSoulFilesService.file_ref_for_key(agent_soul=agent_soul, key=key)
if file_ref is None:
return False
file_id = file_ref.file_id or file_ref.upload_file_id
if not file_id:
return False
raw_kind = file_ref.transfer_method or ("upload_file" if file_ref.upload_file_id else None)
try:
file_kind = AgentDriveFileKind(str(raw_kind))
except ValueError:
return False
if file_kind == AgentDriveFileKind.TOOL_FILE:
return (
session.scalar(select(ToolFile.id).where(ToolFile.tenant_id == tenant_id, ToolFile.id == file_id))
is not None
)
return (
session.scalar(select(UploadFile.id).where(UploadFile.tenant_id == tenant_id, UploadFile.id == file_id))
is not None
)
@classmethod
def sync_agent_bindings_for_draft(
cls,
@@ -231,6 +285,11 @@ 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,
+98 -3
View File
@@ -825,6 +825,24 @@ class AgentDriveService:
except ValueError:
return None
@classmethod
def resolve_download_url_for_ref(
cls,
*,
tenant_id: str,
file_kind: AgentDriveFileKind,
file_id: str,
for_external: bool = False,
as_attachment: bool = False,
) -> str | None:
return cls._resolve_download_url(
tenant_id=tenant_id,
file_kind=file_kind,
file_id=file_id,
for_external=for_external,
as_attachment=as_attachment,
)
# ── console drive inspector (ENG-624) ────────────────────────────────────
# SKILL.md is the primary preview use case; 64 KiB covers it with headroom
@@ -844,15 +862,30 @@ 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:
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 == row.file_id, ToolFile.tenant_id == tenant_id)
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)
@@ -889,6 +922,47 @@ class AgentDriveService:
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text}
def preview_file_ref(
self,
*,
tenant_id: str,
agent_id: str,
key: str,
file_kind: AgentDriveFileKind,
file_id: str,
size: int | None = None,
) -> dict[str, Any]:
"""Preview a concrete versioned file ref recorded in Agent Soul."""
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
storage_key = self._storage_key_for_ref(
session,
tenant_id=tenant_id,
file_kind=file_kind,
file_id=file_id,
)
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])
if b"\x00" in sample:
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
try:
text = sample.decode("utf-8")
except UnicodeDecodeError:
if truncated:
try:
text = sample[:-3].decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
else:
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": key, "size": size, "truncated": truncated, "binary": False, "text": text}
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:
@@ -905,6 +979,27 @@ class AgentDriveService:
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
return url
def download_url_for_ref(
self,
*,
tenant_id: str,
agent_id: str,
file_kind: AgentDriveFileKind,
file_id: str,
) -> str:
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
url = self._resolve_download_url(
tenant_id=tenant_id,
file_kind=file_kind,
file_id=file_id,
for_external=True,
as_attachment=True,
)
if url is None:
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
return url
__all__ = [
"AgentDriveError",
@@ -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():
@@ -386,8 +393,15 @@ 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,
@@ -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:
@@ -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")
@@ -169,12 +169,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 +196,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(
@@ -512,12 +512,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 +574,75 @@ 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 +658,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 +681,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 +699,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": []},
}
),
)
@@ -840,46 +942,29 @@ def _soul_with_drive_skill() -> AgentSoulConfig:
"and [§file:files%2Fsample.pdf:sample.pdf§]."
)
},
files={
"skills": [
{
"id": "tender-analyzer",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
}
],
"files": [{"id": "files/sample.pdf", "name": "sample.pdf", "drive_key": "files/sample.pdf"}],
},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
)
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},
],
)
return None
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: [],
)
return None
def test_build_drive_layer_config_catalogs_drive_skills_and_mentions(monkeypatch: pytest.MonkeyPatch):
@@ -1005,14 +1090,6 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
prompt={
@@ -1021,6 +1098,7 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
"and [§file:files%2Fno-label.txt§]."
)
},
files={"files": [{"id": "files/no-label.txt", "name": "no-label.txt", "drive_key": "files/no-label.txt"}]},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
)
@@ -1094,7 +1172,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 +1192,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()
+23
View File
@@ -7,6 +7,8 @@ from sqlalchemy.exc import IntegrityError
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
@@ -34,6 +36,9 @@ def test_agent_enums_match_prd_boundaries():
assert AgentStatus.ARCHIVED.value == "archived"
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version"
assert AgentConfigRevisionOperation.RESTORE_VERSION.value == "restore_version"
assert AgentConfigRevisionOperation.PUBLISH_DRAFT.value == "publish_draft"
assert AgentConfigDraftType.DRAFT.value == "draft"
assert AgentConfigDraftType.DEBUG_BUILD.value == "debug_build"
assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent"
assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent"
@@ -136,6 +141,23 @@ def test_current_snapshot_stores_agent_soul_snapshot_as_long_text_json():
assert version.config_snapshot_dict["env"]["secret_refs"][0]["provider_credential_id"] == "cred-1"
def test_agent_config_draft_stores_editable_agent_soul_as_long_text_json():
config_snapshot = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "draft prompt"}})
draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
draft_owner_key="",
config_snapshot=config_snapshot,
)
config_snapshot_column = AgentConfigDraft.__table__.c.config_snapshot
assert isinstance(config_snapshot_column.type, JSONModelColumn)
assert config_snapshot_column.server_default is None
assert draft.config_snapshot_dict == config_snapshot.model_dump(mode="json")
assert draft.config_snapshot_dict["prompt"]["system_prompt"] == "draft prompt"
def test_workflow_binding_stores_node_job_config_separately_from_agent_soul():
node_job_config = {
"schema_version": 1,
@@ -166,6 +188,7 @@ def test_long_text_columns_do_not_use_mysql_incompatible_server_defaults():
assert isinstance(column.type, LongText)
assert column.server_default is None
assert AgentConfigSnapshot.__table__.c.config_snapshot.server_default is None
assert AgentConfigDraft.__table__.c.config_snapshot.server_default is None
assert WorkflowAgentNodeBinding.__table__.c.node_job_config.server_default is None
@@ -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
@@ -131,14 +132,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,
@@ -271,11 +273,11 @@ 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, "_save_agent_draft", lambda **kwargs: saved_draft)
monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True})
payload = ComposerSavePayload.model_validate(
{
@@ -293,23 +295,21 @@ 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_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})
payload = ComposerSavePayload.model_validate(
@@ -326,12 +326,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"),
@@ -376,13 +482,27 @@ 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,
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
@@ -1081,6 +1201,16 @@ def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest
"prompt": {
"system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]",
},
"files": {
"skills": [
{
"id": "tender-analyzer",
"name": "Tender Analyzer",
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
}
],
},
}
)
node_job = WorkflowNodeJobConfig.model_validate(
@@ -1160,6 +1290,17 @@ def test_drive_copy_scopes_include_declared_output_benchmark_files():
"[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]"
)
},
"files": {
"skills": [
{
"id": "tender-analyzer",
"name": "Tender Analyzer",
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
}
],
"files": [{"id": "files/source.pdf", "name": "source.pdf", "drive_key": "files/source.pdf"}],
},
}
)
node_job = WorkflowNodeJobConfig.model_validate(
@@ -1568,7 +1709,17 @@ 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 +1731,59 @@ 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)
@@ -1902,6 +2106,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 +2118,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 +2149,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):
@@ -2843,6 +3051,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 +3065,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 +3289,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 +3414,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 +3439,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 +3673,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
@@ -3378,6 +3870,18 @@ def _drive_soul(**overrides):
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
)
},
"files": {
"skills": [
{
"id": "tender-analyzer",
"name": "Tender Analyzer",
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
}
],
"files": [{"id": "files/sample.pdf", "name": "sample.pdf", "drive_key": "files/sample.pdf"}],
},
}
base.update(overrides)
return AgentSoulConfig.model_validate(base)
@@ -3402,7 +3906,7 @@ def test_drive_mention_findings_reports_missing_keys(monkeypatch: pytest.MonkeyP
findings = AgentComposerService._drive_mention_findings(
tenant_id="tenant-1",
agent_id="agent-1",
prompt=_drive_soul().prompt.system_prompt,
agent_soul=_drive_soul(),
)
assert [(f["code"], f["id"]) for f in findings] == [("mention_target_missing", "files/sample.pdf")]
@@ -3417,7 +3921,7 @@ def test_drive_mention_findings_clean_when_all_keys_exist(monkeypatch: pytest.Mo
AgentComposerService._drive_mention_findings(
tenant_id="tenant-1",
agent_id="agent-1",
prompt=_drive_soul().prompt.system_prompt,
agent_soul=_drive_soul(),
)
== []
)
@@ -3429,7 +3933,7 @@ def test_drive_mention_findings_skips_prompt_without_drive_mentions(monkeypatch:
findings = AgentComposerService._drive_mention_findings(
tenant_id="tenant-1",
agent_id="agent-1",
prompt=soul.prompt.system_prompt,
agent_soul=soul,
)
assert findings == []
@@ -0,0 +1,213 @@
import json
from models.agent_config_entities import AgentSoulConfig
from services.agent.soul_files_service import AgentSoulFilesService
def test_apply_drive_commit_records_skill_and_file_refs_in_agent_soul():
soul = AgentSoulConfig()
AgentSoulFilesService._apply_commit_item(
agent_soul=soul,
item={
"key": "tender-analyzer/SKILL.md",
"file_kind": "tool_file",
"file_id": "skill-md-file",
"is_skill": True,
"skill_metadata": json.dumps(
{
"name": "Tender Analyzer",
"description": "Parses tenders.",
"manifest_files": ["SKILL.md", "src/main.py"],
}
),
},
)
AgentSoulFilesService._apply_commit_item(
agent_soul=soul,
item={
"key": "files/sample.pdf",
"file_kind": "upload_file",
"file_id": "upload-file",
"mime_type": "application/pdf",
"is_skill": False,
},
)
AgentSoulFilesService._apply_commit_item(
agent_soul=soul,
item={
"key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"file_kind": "tool_file",
"file_id": "archive-file",
"mime_type": "application/zip",
"is_skill": False,
},
)
AgentSoulFilesService._apply_commit_item(
agent_soul=soul,
item={
"key": "tender-analyzer/src/main.py",
"file_kind": "tool_file",
"file_id": "member-file",
"mime_type": "text/x-python",
"is_skill": False,
},
)
assert [skill.model_dump(mode="json", exclude_none=True) for skill in soul.files.skills] == [
{
"id": "tender-analyzer",
"name": "Tender Analyzer",
"description": "Parses tenders.",
"file_id": "skill-md-file",
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"skill_md_file_id": "skill-md-file",
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"full_archive_file_id": "archive-file",
"manifest_files": ["SKILL.md", "src/main.py"],
"file_refs": [
{
"id": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"file_id": "archive-file",
"name": ".DIFY-SKILL-FULL.zip",
"type": "application/zip",
"transfer_method": "tool_file",
"drive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
},
{
"id": "tender-analyzer/SKILL.md",
"file_id": "skill-md-file",
"name": "SKILL.md",
"type": "",
"transfer_method": "tool_file",
"drive_key": "tender-analyzer/SKILL.md",
},
{
"id": "tender-analyzer/src/main.py",
"file_id": "member-file",
"name": "main.py",
"type": "text/x-python",
"transfer_method": "tool_file",
"drive_key": "tender-analyzer/src/main.py",
},
],
}
]
assert [file_ref.model_dump(mode="json", exclude_none=True) for file_ref in soul.files.files] == [
{
"id": "files/sample.pdf",
"file_id": "upload-file",
"upload_file_id": "upload-file",
"name": "sample.pdf",
"type": "application/pdf",
"transfer_method": "upload_file",
"drive_key": "files/sample.pdf",
}
]
def test_apply_drive_commit_removes_refs_without_touching_unrelated_entries():
soul = AgentSoulConfig.model_validate(
{
"files": {
"skills": [
{
"id": "tender-analyzer",
"name": "Tender Analyzer",
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"file_refs": [
{
"id": "tender-analyzer/SKILL.md",
"file_id": "skill-md-file",
"name": "SKILL.md",
"type": "",
"transfer_method": "tool_file",
"drive_key": "tender-analyzer/SKILL.md",
},
{
"id": "tender-analyzer/src/main.py",
"file_id": "member-file",
"name": "main.py",
"transfer_method": "tool_file",
"drive_key": "tender-analyzer/src/main.py",
},
],
}
],
"files": [
{"id": "files/sample.pdf", "name": "sample.pdf", "drive_key": "files/sample.pdf"},
{"id": "files/keep.pdf", "name": "keep.pdf", "drive_key": "files/keep.pdf"},
],
}
}
)
AgentSoulFilesService._apply_commit_item(agent_soul=soul, item={"key": "files/sample.pdf", "removed": True})
AgentSoulFilesService._apply_commit_item(
agent_soul=soul, item={"key": "tender-analyzer/SKILL.md", "removed": True}
)
assert [file_ref.drive_key for file_ref in soul.files.files] == ["files/keep.pdf"]
assert soul.files.skills == []
def test_drive_copy_and_access_scopes_come_from_agent_soul_files():
soul = AgentSoulConfig.model_validate(
{
"files": {
"skills": [
{
"id": "tender-analyzer",
"name": "Tender Analyzer",
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
}
],
"files": [{"id": "files/sample.pdf", "name": "sample.pdf", "drive_key": "files/sample.pdf"}],
}
}
)
exact_keys, prefixes = AgentSoulFilesService.drive_copy_scopes(agent_soul=soul)
assert exact_keys == {
"tender-analyzer/SKILL.md",
"tender-analyzer/.DIFY-SKILL-FULL.zip",
"files/sample.pdf",
}
assert prefixes == {"tender-analyzer/"}
assert AgentSoulFilesService.key_allowed_by_soul(agent_soul=soul, key="tender-analyzer/src/main.py") is True
assert AgentSoulFilesService.key_allowed_by_soul(agent_soul=soul, key="files/other.pdf") is False
def test_file_ref_for_key_resolves_skill_member_refs():
soul = AgentSoulConfig.model_validate(
{
"files": {
"skills": [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"file_refs": [
{
"id": "tender-analyzer/src/main.py",
"file_id": "member-file",
"name": "main.py",
"transfer_method": "tool_file",
"drive_key": "tender-analyzer/src/main.py",
}
],
}
]
}
}
)
file_ref = AgentSoulFilesService.file_ref_for_key(agent_soul=soul, key="tender-analyzer/src/main.py")
assert file_ref is not None
assert file_ref.file_id == "member-file"
@@ -124,7 +124,18 @@ def _soul() -> AgentSoulConfig:
{"id": "ct-2", "name": "disabled-one", "enabled": False},
],
},
"knowledge": {"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}]},
"knowledge": {
"sets": [
{
"id": "kb-1",
"name": "产品知识",
"description": "knowledge set",
"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
},
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
}
)
@@ -143,12 +154,16 @@ def test_soul_candidates_lists_configured_items_only():
assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"]
# the stable mention id flows through so the frontend can mint [§cli_tool:<id>§]
assert [item["id"] for item in lists["cli_tools"]] == ["ct-1"]
# enriched from DB; dangling dataset kept with missing flag (placeholder, 0522)
knowledge = {item["id"]: item for item in lists["knowledge_datasets"]}
assert knowledge["ds-1"]["name"] == "产品手册"
assert knowledge["ds-1"]["missing"] is False
assert knowledge["ds-gone"]["missing"] is True
assert knowledge["ds-gone"]["name"] == "已删"
# Knowledge mentions point at set ids; nested datasets are hydrated for context.
knowledge_set = lists["knowledge_sets"][0]
assert knowledge_set["id"] == "kb-1"
assert knowledge_set["name"] == "产品知识"
assert knowledge_set["missing_dataset_ids"] == ["ds-gone"]
datasets = {item["id"]: item for item in knowledge_set["datasets"]}
assert datasets["ds-1"]["name"] == "产品手册"
assert datasets["ds-1"]["missing"] is False
assert datasets["ds-gone"]["missing"] is True
assert datasets["ds-gone"]["name"] == "已删"
assert lists["human_contacts"][0]["id"] == "c-1"
assert lists["dify_tools"][0]["id"] == "tavily/tavily_search"
@@ -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)
@@ -65,6 +65,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members()
user_id="user-1",
agent_id="agent-1",
)
assert service.last_committed_items == []
# ToolFiles: SKILL.md, full archive, and each inspectable package member.
assert tool_files.create_file_by_raw.call_count == 3
@@ -102,3 +103,4 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members()
assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
assert result["manifest"]["files"] == ["SKILL.md", "scripts/run.py"]
assert "_committed_items" not in result
@@ -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
@@ -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",
]
@@ -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())
@@ -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},
}
],
}
)
)
@@ -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']",
],
-5
View File
@@ -878,11 +878,6 @@
"count": 1
}
},
"web/app/components/app/overview/customize/index.tsx": {
"jsx-a11y/anchor-has-content": {
"count": 3
}
},
"web/app/components/app/overview/settings/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@@ -46,7 +46,7 @@ export type AgentAppDetailWithSite = {
name: string
permission_keys?: Array<string>
role?: string | null
site?: Site | null
site?: AppDetailSiteResponse | null
tags?: Array<Tag>
tracing?: JsonValue | null
updated_at?: number | null
@@ -413,21 +413,31 @@ export type ModelConfig = {
provider: string
}
export type Site = {
export type AppDetailSiteResponse = {
access_token?: string | null
app_base_url?: string | null
chat_color_theme?: string | null
chat_color_theme_inverted: boolean
chat_color_theme_inverted?: boolean | null
code?: string | null
copyright?: string | null
created_at?: number | null
created_by?: string | null
custom_disclaimer?: string | null
default_language: string
customize_domain?: string | null
customize_token_strategy?: string | null
default_language?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
icon_type?: string | IconType | null
readonly icon_url: string | null
privacy_policy?: string | null
show_workflow_steps: boolean
title: string
use_icon_as_answer_icon: boolean
prompt_public?: boolean | null
show_workflow_steps?: boolean | null
title?: string | null
updated_at?: number | null
updated_by?: string | null
use_icon_as_answer_icon?: boolean | null
}
export type Tag = {
@@ -502,8 +512,12 @@ export type AgentConfigSnapshotSummaryResponse = {
export type AgentComposerAgentResponse = {
active_config_snapshot_id?: string | null
description: string
icon?: string | null
icon_background?: string | null
icon_type?: string | null
id: string
name: string
role?: string | null
scope: AgentScope
status: AgentStatus
}
@@ -570,7 +584,7 @@ export type AgentComposerSoulCandidatesResponse = {
cli_tools?: Array<AgentCliToolConfig>
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
human_contacts?: Array<AgentHumanContactConfig>
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
knowledge_sets?: Array<AgentComposerKnowledgeSetCandidateResponse>
}
export type ComposerCandidateCapabilities = {
@@ -931,9 +945,7 @@ export type AgentSoulHumanConfig = {
}
export type AgentSoulKnowledgeConfig = {
datasets?: Array<AgentKnowledgeDatasetConfig>
query_config?: AgentKnowledgeQueryConfig
query_mode?: AgentKnowledgeQueryMode | null
sets?: Array<AgentKnowledgeSetConfig>
}
export type AgentSoulMemoryConfig = {
@@ -1074,11 +1086,12 @@ export type AgentComposerDifyToolCandidateResponse = {
tools_count?: number | null
}
export type AgentKnowledgeDatasetConfig = {
export type AgentComposerKnowledgeSetCandidateResponse = {
datasets?: Array<AgentComposerKnowledgeDatasetCandidateResponse>
description?: string | null
id?: string | null
name?: string | null
[key: string]: unknown
id: string
missing_dataset_ids?: Array<string>
name: string
}
export type AgentModerationProviderConfig = {
@@ -1233,16 +1246,16 @@ export type AgentHumanToolConfig = {
[key: string]: unknown
}
export type AgentKnowledgeQueryConfig = {
query?: string | null
score_threshold?: number | null
score_threshold_enabled?: boolean | null
top_k?: number | null
[key: string]: unknown
export type AgentKnowledgeSetConfig = {
datasets: Array<AgentKnowledgeDatasetConfig>
description?: string | null
id: string
metadata_filtering?: AgentKnowledgeMetadataFilteringConfig
name: string
query: AgentKnowledgeQueryConfig
retrieval: AgentKnowledgeRetrievalConfig
}
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentMemoryArtifactConfig = {
id?: string | null
name?: string | null
@@ -1381,6 +1394,13 @@ export type AgentPermissionConfig = {
export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown'
export type AgentComposerKnowledgeDatasetCandidateResponse = {
description?: string | null
id?: string | null
missing?: boolean
name?: string | null
}
export type AgentModerationIoConfig = {
enabled?: boolean
preset_response?: string | null
@@ -1409,6 +1429,34 @@ export type FormInputConfig
export type JsonValue2 = unknown
export type AgentKnowledgeDatasetConfig = {
description?: string | null
id?: string | null
name?: string | null
}
export type AgentKnowledgeMetadataFilteringConfig = {
conditions?: AgentKnowledgeMetadataConditions | null
mode?: 'automatic' | 'disabled' | 'manual'
model_config?: AgentKnowledgeModelConfig | null
}
export type AgentKnowledgeQueryConfig = {
mode: AgentKnowledgeQueryMode
value?: string | null
}
export type AgentKnowledgeRetrievalConfig = {
mode: 'multiple' | 'single'
model?: AgentKnowledgeModelConfig | null
reranking_enable?: boolean
reranking_mode?: string
reranking_model?: AgentKnowledgeRerankingModelConfig | null
score_threshold?: number | null
top_k?: number | null
weights?: AgentKnowledgeWeightedScoreConfig | null
}
export type AgentModelResponseFormatConfig = {
type?: string | null
[key: string]: unknown
@@ -1459,6 +1507,38 @@ export type FileListInputConfig = {
type?: 'file-list'
}
export type AgentKnowledgeMetadataConditions = {
conditions?: Array<AgentKnowledgeMetadataCondition>
logical_operator?: 'and' | 'or'
}
export type AgentKnowledgeModelConfig = {
completion_params?: {
[key: string]: unknown
}
mode: string
name: string
provider: string
}
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentKnowledgeRerankingModelConfig = {
model: string
provider: string
}
export type AgentKnowledgeWeightedScoreConfig = {
keyword_setting?: {
[key: string]: unknown
} | null
vector_setting?: {
[key: string]: unknown
} | null
weight_type?: string | null
[key: string]: unknown
}
export type StringSource = {
selector?: Array<string>
type: ValueSourceType
@@ -1475,6 +1555,30 @@ export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
export type AgentKnowledgeMetadataCondition = {
comparison_operator:
| '<'
| '='
| '>'
| 'after'
| 'before'
| 'contains'
| 'empty'
| 'end with'
| 'in'
| 'is'
| 'is not'
| 'not contains'
| 'not empty'
| 'not in'
| 'start with'
| '≠'
| '≤'
| '≥'
name: string
value?: string | Array<string> | number | null
}
export type ValueSourceType = 'constant' | 'variable'
export type AgentAppPaginationWritable = {
@@ -1509,7 +1613,7 @@ export type AgentAppDetailWithSiteWritable = {
name: string
permission_keys?: Array<string>
role?: string | null
site?: SiteWritable | null
site?: AppDetailSiteResponseWritable | null
tags?: Array<Tag>
tracing?: JsonValue | null
updated_at?: number | null
@@ -1551,20 +1655,30 @@ export type AgentAppPartialWritable = {
workflow?: WorkflowPartial | null
}
export type SiteWritable = {
export type AppDetailSiteResponseWritable = {
access_token?: string | null
app_base_url?: string | null
chat_color_theme?: string | null
chat_color_theme_inverted: boolean
chat_color_theme_inverted?: boolean | null
code?: string | null
copyright?: string | null
created_at?: number | null
created_by?: string | null
custom_disclaimer?: string | null
default_language: string
customize_domain?: string | null
customize_token_strategy?: string | null
default_language?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
icon_type?: string | IconType | null
privacy_policy?: string | null
show_workflow_steps: boolean
title: string
use_icon_as_answer_icon: boolean
prompt_public?: boolean | null
show_workflow_steps?: boolean | null
title?: string | null
updated_at?: number | null
updated_by?: string | null
use_icon_as_answer_icon?: boolean | null
}
export type GetAgentData = {
@@ -190,23 +190,33 @@ export const zDeletedTool = z.object({
})
/**
* Site
* AppDetailSiteResponse
*/
export const zSite = z.object({
export const zAppDetailSiteResponse = z.object({
access_token: z.string().nullish(),
app_base_url: z.string().nullish(),
chat_color_theme: z.string().nullish(),
chat_color_theme_inverted: z.boolean(),
chat_color_theme_inverted: z.boolean().nullish(),
code: z.string().nullish(),
copyright: z.string().nullish(),
created_at: z.int().nullish(),
created_by: z.string().nullish(),
custom_disclaimer: z.string().nullish(),
default_language: z.string(),
customize_domain: z.string().nullish(),
customize_token_strategy: z.string().nullish(),
default_language: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_type: z.union([z.string(), zIconType]).nullish(),
icon_url: z.string().nullable(),
privacy_policy: z.string().nullish(),
show_workflow_steps: z.boolean(),
title: z.string(),
use_icon_as_answer_icon: z.boolean(),
prompt_public: z.boolean().nullish(),
show_workflow_steps: z.boolean().nullish(),
title: z.string().nullish(),
updated_at: z.int().nullish(),
updated_by: z.string().nullish(),
use_icon_as_answer_icon: z.boolean().nullish(),
})
/**
@@ -818,7 +828,7 @@ export const zAgentAppDetailWithSite = z.object({
name: z.string(),
permission_keys: z.array(z.string()).optional(),
role: z.string().nullish(),
site: zSite.nullish(),
site: zAppDetailSiteResponse.nullish(),
tags: z.array(zTag).optional(),
tracing: zJsonValue.nullish(),
updated_at: z.int().nullish(),
@@ -926,8 +936,12 @@ export const zAgentInviteOptionsResponse = z.object({
export const zAgentComposerAgentResponse = z.object({
active_config_snapshot_id: z.string().nullish(),
description: z.string(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
id: z.string(),
name: z.string(),
role: z.string().nullish(),
scope: zAgentScope,
status: zAgentStatus,
})
@@ -1022,15 +1036,6 @@ export const zAgentComposerDifyToolCandidateResponse = z.object({
tools_count: z.int().nullish(),
})
/**
* AgentKnowledgeDatasetConfig
*/
export const zAgentKnowledgeDatasetConfig = z.object({
description: z.string().nullish(),
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
})
/**
* SimpleAccount
*/
@@ -1279,30 +1284,6 @@ export const zAgentSoulHumanConfig = z.object({
tools: z.array(zAgentHumanToolConfig).optional(),
})
/**
* AgentKnowledgeQueryConfig
*/
export const zAgentKnowledgeQueryConfig = z.object({
query: z.string().nullish(),
score_threshold: z.number().gte(0).lte(1).nullish(),
score_threshold_enabled: z.boolean().nullish(),
top_k: z.int().gte(1).nullish(),
})
/**
* AgentKnowledgeQueryMode
*/
export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'])
/**
* AgentSoulKnowledgeConfig
*/
export const zAgentSoulKnowledgeConfig = z.object({
datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
query_config: zAgentKnowledgeQueryConfig.optional(),
query_mode: zAgentKnowledgeQueryMode.nullish(),
})
/**
* AgentMemoryArtifactConfig
*/
@@ -1521,6 +1502,27 @@ export const zAgentCliToolConfig = z.object({
tool_name: z.string().max(255).nullish(),
})
/**
* AgentComposerKnowledgeDatasetCandidateResponse
*/
export const zAgentComposerKnowledgeDatasetCandidateResponse = z.object({
description: z.string().nullish(),
id: z.string().max(255).nullish(),
missing: z.boolean().optional().default(false),
name: z.string().max(255).nullish(),
})
/**
* AgentComposerKnowledgeSetCandidateResponse
*/
export const zAgentComposerKnowledgeSetCandidateResponse = z.object({
datasets: z.array(zAgentComposerKnowledgeDatasetCandidateResponse).optional(),
description: z.string().nullish(),
id: z.string(),
missing_dataset_ids: z.array(z.string()).optional(),
name: z.string(),
})
/**
* AgentComposerSoulCandidatesResponse
*/
@@ -1528,7 +1530,7 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
cli_tools: z.array(zAgentCliToolConfig).optional(),
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
human_contacts: z.array(zAgentHumanContactConfig).optional(),
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
knowledge_sets: z.array(zAgentComposerKnowledgeSetCandidateResponse).optional(),
})
/**
@@ -1583,6 +1585,15 @@ export const zHumanInputFormSubmissionData = z.object({
submitted_data: z.record(z.string(), zJsonValue2).nullish(),
})
/**
* AgentKnowledgeDatasetConfig
*/
export const zAgentKnowledgeDatasetConfig = z.object({
description: z.string().nullish(),
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
})
/**
* AgentModelResponseFormatConfig
*/
@@ -1733,53 +1744,6 @@ export const zAgentSoulToolsConfig = z.object({
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
})
/**
* AgentSoulConfig
*/
export const zAgentSoulConfig = z.object({
app_features: zAgentSoulAppFeaturesConfig.optional(),
app_variables: z.array(zAppVariableConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
misc_legacy: zAgentSoulAppFeaturesConfig.optional(),
model: zAgentSoulModelConfig.nullish(),
prompt: zAgentSoulPromptConfig.optional(),
sandbox: zAgentSoulSandboxConfig.optional(),
schema_version: z.int().optional().default(1),
tools: zAgentSoulToolsConfig.optional(),
})
/**
* AgentAppComposerResponse
*/
export const zAgentAppComposerResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse,
agent: zAgentComposerAgentResponse,
agent_soul: zAgentSoulConfig,
save_options: z.array(zComposerSaveStrategy),
validation: zComposerValidationFindingsResponse.nullish(),
variant: z.literal('agent_app'),
})
/**
* AgentConfigSnapshotDetailResponse
*/
export const zAgentConfigSnapshotDetailResponse = z.object({
agent_id: z.string().nullish(),
config_snapshot: zAgentSoulConfig,
created_at: z.int().nullish(),
created_by: z.string().nullish(),
display_version: z.int().nullish(),
id: z.string(),
revisions: z.array(zAgentConfigRevisionResponse).optional(),
snapshot_version: z.int().nullish(),
summary: z.string().nullish(),
version: z.int(),
version_note: z.string().nullish(),
})
/**
* OutputErrorStrategy
*
@@ -1869,27 +1833,6 @@ export const zWorkflowNodeJobConfig = z.object({
workflow_prompt: z.string().optional().default(''),
})
/**
* ComposerSavePayload
*/
export const zComposerSavePayload = z.object({
agent_soul: zAgentSoulConfig.nullish(),
binding: zComposerBindingPayload.nullish(),
client_revision_id: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().max(255).nullish(),
icon_background: z.string().max(255).nullish(),
icon_type: zAgentIconType.nullish(),
idempotency_key: z.string().nullish(),
new_agent_name: z.string().min(1).max(255).nullish(),
node_job: zWorkflowNodeJobConfig.nullish(),
role: z.string().max(255).nullish(),
save_strategy: zComposerSaveStrategy,
soul_lock: zComposerSoulLockPayload.optional(),
variant: zComposerVariant,
version_note: z.string().nullish(),
})
/**
* ButtonStyle
*
@@ -1908,6 +1851,73 @@ export const zUserActionConfig = z.object({
title: z.string().max(100),
})
/**
* AgentKnowledgeModelConfig
*/
export const zAgentKnowledgeModelConfig = z.object({
completion_params: z.record(z.string(), z.unknown()).optional(),
mode: z.string().min(1).max(64),
name: z.string().min(1).max(255),
provider: z.string().min(1).max(255),
})
/**
* AgentKnowledgeQueryMode
*/
export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'])
/**
* 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.
*/
export const zAgentKnowledgeQueryConfig = z.object({
mode: zAgentKnowledgeQueryMode,
value: z.string().nullish(),
})
/**
* AgentKnowledgeRerankingModelConfig
*/
export const zAgentKnowledgeRerankingModelConfig = z.object({
model: z.string().min(1).max(255),
provider: z.string().min(1).max(255),
})
/**
* AgentKnowledgeWeightedScoreConfig
*/
export const zAgentKnowledgeWeightedScoreConfig = z.object({
keyword_setting: z.record(z.string(), z.unknown()).nullish(),
vector_setting: z.record(z.string(), z.unknown()).nullish(),
weight_type: z.string().max(64).nullish(),
})
/**
* 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.
*/
export const zAgentKnowledgeRetrievalConfig = z.object({
mode: z.enum(['multiple', 'single']),
model: zAgentKnowledgeModelConfig.nullish(),
reranking_enable: z.boolean().optional().default(true),
reranking_mode: z.string().optional().default('reranking_model'),
reranking_model: zAgentKnowledgeRerankingModelConfig.nullish(),
score_threshold: z.number().gte(0).lte(1).nullish(),
top_k: z.int().gte(1).nullish(),
weights: zAgentKnowledgeWeightedScoreConfig.nullish(),
})
/**
* FileType
*/
@@ -1946,6 +1956,160 @@ export const zFileListInputConfig = z.object({
type: z.literal('file-list').optional().default('file-list'),
})
/**
* AgentKnowledgeMetadataCondition
*/
export const zAgentKnowledgeMetadataCondition = z.object({
comparison_operator: z.enum([
'<',
'=',
'>',
'after',
'before',
'contains',
'empty',
'end with',
'in',
'is',
'is not',
'not contains',
'not empty',
'not in',
'start with',
'≠',
'≤',
'≥',
]),
name: z.string().min(1).max(255),
value: z.union([z.string(), z.array(z.string()), z.number()]).nullish(),
})
/**
* AgentKnowledgeMetadataConditions
*/
export const zAgentKnowledgeMetadataConditions = z.object({
conditions: z.array(zAgentKnowledgeMetadataCondition).optional(),
logical_operator: z.enum(['and', 'or']).optional().default('and'),
})
/**
* 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.
*/
export const zAgentKnowledgeMetadataFilteringConfig = z.object({
conditions: zAgentKnowledgeMetadataConditions.nullish(),
mode: z.enum(['automatic', 'disabled', 'manual']).optional().default('disabled'),
model_config: zAgentKnowledgeModelConfig.nullish(),
})
/**
* 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".
*/
export const zAgentKnowledgeSetConfig = z.object({
datasets: z.array(zAgentKnowledgeDatasetConfig),
description: z.string().nullish(),
id: z.string().min(1).max(255),
metadata_filtering: zAgentKnowledgeMetadataFilteringConfig.optional(),
name: z.string().min(1).max(255),
query: zAgentKnowledgeQueryConfig,
retrieval: zAgentKnowledgeRetrievalConfig,
})
/**
* 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.
*/
export const zAgentSoulKnowledgeConfig = z.object({
sets: z.array(zAgentKnowledgeSetConfig).optional(),
})
/**
* AgentSoulConfig
*/
export const zAgentSoulConfig = z.object({
app_features: zAgentSoulAppFeaturesConfig.optional(),
app_variables: z.array(zAppVariableConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
misc_legacy: zAgentSoulAppFeaturesConfig.optional(),
model: zAgentSoulModelConfig.nullish(),
prompt: zAgentSoulPromptConfig.optional(),
sandbox: zAgentSoulSandboxConfig.optional(),
schema_version: z.int().optional().default(1),
tools: zAgentSoulToolsConfig.optional(),
})
/**
* AgentAppComposerResponse
*/
export const zAgentAppComposerResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse,
agent: zAgentComposerAgentResponse,
agent_soul: zAgentSoulConfig,
save_options: z.array(zComposerSaveStrategy),
validation: zComposerValidationFindingsResponse.nullish(),
variant: z.literal('agent_app'),
})
/**
* ComposerSavePayload
*/
export const zComposerSavePayload = z.object({
agent_soul: zAgentSoulConfig.nullish(),
binding: zComposerBindingPayload.nullish(),
client_revision_id: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().max(255).nullish(),
icon_background: z.string().max(255).nullish(),
icon_type: zAgentIconType.nullish(),
idempotency_key: z.string().nullish(),
new_agent_name: z.string().min(1).max(255).nullish(),
node_job: zWorkflowNodeJobConfig.nullish(),
role: z.string().max(255).nullish(),
save_strategy: zComposerSaveStrategy,
soul_lock: zComposerSoulLockPayload.optional(),
variant: zComposerVariant,
version_note: z.string().nullish(),
})
/**
* AgentConfigSnapshotDetailResponse
*/
export const zAgentConfigSnapshotDetailResponse = z.object({
agent_id: z.string().nullish(),
config_snapshot: zAgentSoulConfig,
created_at: z.int().nullish(),
created_by: z.string().nullish(),
display_version: z.int().nullish(),
id: z.string(),
revisions: z.array(zAgentConfigRevisionResponse).optional(),
snapshot_version: z.int().nullish(),
summary: z.string().nullish(),
version: z.int(),
version_note: z.string().nullish(),
})
/**
* ValueSourceType
*
@@ -2115,22 +2279,32 @@ export const zAgentAppPaginationWritable = z.object({
})
/**
* Site
* AppDetailSiteResponse
*/
export const zSiteWritable = z.object({
export const zAppDetailSiteResponseWritable = z.object({
access_token: z.string().nullish(),
app_base_url: z.string().nullish(),
chat_color_theme: z.string().nullish(),
chat_color_theme_inverted: z.boolean(),
chat_color_theme_inverted: z.boolean().nullish(),
code: z.string().nullish(),
copyright: z.string().nullish(),
created_at: z.int().nullish(),
created_by: z.string().nullish(),
custom_disclaimer: z.string().nullish(),
default_language: z.string(),
customize_domain: z.string().nullish(),
customize_token_strategy: z.string().nullish(),
default_language: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_type: z.union([z.string(), zIconType]).nullish(),
privacy_policy: z.string().nullish(),
show_workflow_steps: z.boolean(),
title: z.string(),
use_icon_as_answer_icon: z.boolean(),
prompt_public: z.boolean().nullish(),
show_workflow_steps: z.boolean().nullish(),
title: z.string().nullish(),
updated_at: z.int().nullish(),
updated_by: z.string().nullish(),
use_icon_as_answer_icon: z.boolean().nullish(),
})
/**
@@ -2160,7 +2334,7 @@ export const zAgentAppDetailWithSiteWritable = z.object({
name: z.string(),
permission_keys: z.array(z.string()).optional(),
role: z.string().nullish(),
site: zSiteWritable.nullish(),
site: zAppDetailSiteResponseWritable.nullish(),
tags: z.array(zTag).optional(),
tracing: zJsonValue.nullish(),
updated_at: z.int().nullish(),
@@ -43,7 +43,7 @@ export type AppDetailWithSite = {
model_config?: ModelConfig | null
name: string
permission_keys?: Array<string>
site?: Site | null
site?: AppDetailSiteResponse | null
tags?: Array<Tag>
tracing?: JsonValue | null
updated_at?: number | null
@@ -1231,21 +1231,31 @@ export type ModelConfig = {
provider: string
}
export type Site = {
export type AppDetailSiteResponse = {
access_token?: string | null
app_base_url?: string | null
chat_color_theme?: string | null
chat_color_theme_inverted: boolean
chat_color_theme_inverted?: boolean | null
code?: string | null
copyright?: string | null
created_at?: number | null
created_by?: string | null
custom_disclaimer?: string | null
default_language: string
customize_domain?: string | null
customize_token_strategy?: string | null
default_language?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
icon_type?: string | IconType | null
readonly icon_url: string | null
privacy_policy?: string | null
show_workflow_steps: boolean
title: string
use_icon_as_answer_icon: boolean
prompt_public?: boolean | null
show_workflow_steps?: boolean | null
title?: string | null
updated_at?: number | null
updated_by?: string | null
use_icon_as_answer_icon?: boolean | null
}
export type Tag = {
@@ -1788,8 +1798,12 @@ export type AgentConfigSnapshotSummaryResponse = {
export type AgentComposerAgentResponse = {
active_config_snapshot_id?: string | null
description: string
icon?: string | null
icon_background?: string | null
icon_type?: string | null
id: string
name: string
role?: string | null
scope: AgentScope
status: AgentStatus
}
@@ -1903,7 +1917,7 @@ export type AgentComposerSoulCandidatesResponse = {
cli_tools?: Array<AgentCliToolConfig>
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
human_contacts?: Array<AgentHumanContactConfig>
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
knowledge_sets?: Array<AgentComposerKnowledgeSetCandidateResponse>
}
export type ComposerCandidateCapabilities = {
@@ -2137,9 +2151,7 @@ export type AgentSoulHumanConfig = {
}
export type AgentSoulKnowledgeConfig = {
datasets?: Array<AgentKnowledgeDatasetConfig>
query_config?: AgentKnowledgeQueryConfig
query_mode?: AgentKnowledgeQueryMode | null
sets?: Array<AgentKnowledgeSetConfig>
}
export type AgentSoulMemoryConfig = {
@@ -2291,11 +2303,12 @@ export type AgentComposerDifyToolCandidateResponse = {
tools_count?: number | null
}
export type AgentKnowledgeDatasetConfig = {
export type AgentComposerKnowledgeSetCandidateResponse = {
datasets?: Array<AgentComposerKnowledgeDatasetCandidateResponse>
description?: string | null
id?: string | null
name?: string | null
[key: string]: unknown
id: string
missing_dataset_ids?: Array<string>
name: string
}
export type CheckResultView = {
@@ -2406,16 +2419,16 @@ export type AgentHumanToolConfig = {
[key: string]: unknown
}
export type AgentKnowledgeQueryConfig = {
query?: string | null
score_threshold?: number | null
score_threshold_enabled?: boolean | null
top_k?: number | null
[key: string]: unknown
export type AgentKnowledgeSetConfig = {
datasets: Array<AgentKnowledgeDatasetConfig>
description?: string | null
id: string
metadata_filtering?: AgentKnowledgeMetadataFilteringConfig
name: string
query: AgentKnowledgeQueryConfig
retrieval: AgentKnowledgeRetrievalConfig
}
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentMemoryArtifactConfig = {
id?: string | null
name?: string | null
@@ -2519,6 +2532,13 @@ export type AgentPermissionConfig = {
export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown'
export type AgentComposerKnowledgeDatasetCandidateResponse = {
description?: string | null
id?: string | null
missing?: boolean
name?: string | null
}
export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary'
export type ParagraphInputConfig = {
@@ -2558,6 +2578,34 @@ export type AgentModerationProviderConfig = {
[key: string]: unknown
}
export type AgentKnowledgeDatasetConfig = {
description?: string | null
id?: string | null
name?: string | null
}
export type AgentKnowledgeMetadataFilteringConfig = {
conditions?: AgentKnowledgeMetadataConditions | null
mode?: 'automatic' | 'disabled' | 'manual'
model_config?: AgentKnowledgeModelConfig | null
}
export type AgentKnowledgeQueryConfig = {
mode: AgentKnowledgeQueryMode
value?: string | null
}
export type AgentKnowledgeRetrievalConfig = {
mode: 'multiple' | 'single'
model?: AgentKnowledgeModelConfig | null
reranking_enable?: boolean
reranking_mode?: string
reranking_model?: AgentKnowledgeRerankingModelConfig | null
score_threshold?: number | null
top_k?: number | null
weights?: AgentKnowledgeWeightedScoreConfig | null
}
export type AgentModelResponseFormatConfig = {
type?: string | null
[key: string]: unknown
@@ -2591,8 +2639,64 @@ export type AgentModerationIoConfig = {
[key: string]: unknown
}
export type AgentKnowledgeMetadataConditions = {
conditions?: Array<AgentKnowledgeMetadataCondition>
logical_operator?: 'and' | 'or'
}
export type AgentKnowledgeModelConfig = {
completion_params?: {
[key: string]: unknown
}
mode: string
name: string
provider: string
}
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentKnowledgeRerankingModelConfig = {
model: string
provider: string
}
export type AgentKnowledgeWeightedScoreConfig = {
keyword_setting?: {
[key: string]: unknown
} | null
vector_setting?: {
[key: string]: unknown
} | null
weight_type?: string | null
[key: string]: unknown
}
export type ValueSourceType = 'constant' | 'variable'
export type AgentKnowledgeMetadataCondition = {
comparison_operator:
| '<'
| '='
| '>'
| 'after'
| 'before'
| 'contains'
| 'empty'
| 'end with'
| 'in'
| 'is'
| 'is not'
| 'not contains'
| 'not empty'
| 'not in'
| 'start with'
| '≠'
| '≤'
| '≥'
name: string
value?: string | Array<string> | number | null
}
export type AppPaginationWritable = {
data: Array<AppPartialWritable>
has_more: boolean
@@ -2622,7 +2726,7 @@ export type AppDetailWithSiteWritable = {
model_config?: ModelConfig | null
name: string
permission_keys?: Array<string>
site?: SiteWritable | null
site?: AppDetailSiteResponseWritable | null
tags?: Array<Tag>
tracing?: JsonValue | null
updated_at?: number | null
@@ -2682,20 +2786,30 @@ export type AppPartialWritable = {
workflow?: WorkflowPartial | null
}
export type SiteWritable = {
export type AppDetailSiteResponseWritable = {
access_token?: string | null
app_base_url?: string | null
chat_color_theme?: string | null
chat_color_theme_inverted: boolean
chat_color_theme_inverted?: boolean | null
code?: string | null
copyright?: string | null
created_at?: number | null
created_by?: string | null
custom_disclaimer?: string | null
default_language: string
customize_domain?: string | null
customize_token_strategy?: string | null
default_language?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
icon_type?: string | IconType | null
privacy_policy?: string | null
show_workflow_steps: boolean
title: string
use_icon_as_answer_icon: boolean
prompt_public?: boolean | null
show_workflow_steps?: boolean | null
title?: string | null
updated_at?: number | null
updated_by?: string | null
use_icon_as_answer_icon?: boolean | null
}
export type WorkflowCommentBasicWritable = {
@@ -845,23 +845,33 @@ export const zDeletedTool = z.object({
})
/**
* Site
* AppDetailSiteResponse
*/
export const zSite = z.object({
export const zAppDetailSiteResponse = z.object({
access_token: z.string().nullish(),
app_base_url: z.string().nullish(),
chat_color_theme: z.string().nullish(),
chat_color_theme_inverted: z.boolean(),
chat_color_theme_inverted: z.boolean().nullish(),
code: z.string().nullish(),
copyright: z.string().nullish(),
created_at: z.int().nullish(),
created_by: z.string().nullish(),
custom_disclaimer: z.string().nullish(),
default_language: z.string(),
customize_domain: z.string().nullish(),
customize_token_strategy: z.string().nullish(),
default_language: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_type: z.union([z.string(), zIconType]).nullish(),
icon_url: z.string().nullable(),
privacy_policy: z.string().nullish(),
show_workflow_steps: z.boolean(),
title: z.string(),
use_icon_as_answer_icon: z.boolean(),
prompt_public: z.boolean().nullish(),
show_workflow_steps: z.boolean().nullish(),
title: z.string().nullish(),
updated_at: z.int().nullish(),
updated_by: z.string().nullish(),
use_icon_as_answer_icon: z.boolean().nullish(),
})
/**
@@ -2098,7 +2108,7 @@ export const zAppDetailWithSite = z.object({
model_config: zModelConfig.nullish(),
name: z.string(),
permission_keys: z.array(z.string()).optional(),
site: zSite.nullish(),
site: zAppDetailSiteResponse.nullish(),
tags: z.array(zTag).optional(),
tracing: zJsonValue.nullish(),
updated_at: z.int().nullish(),
@@ -2499,8 +2509,12 @@ export const zAgentStatus = z.enum(['active', 'archived'])
export const zAgentComposerAgentResponse = z.object({
active_config_snapshot_id: z.string().nullish(),
description: z.string(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
id: z.string(),
name: z.string(),
role: z.string().nullish(),
scope: zAgentScope,
status: zAgentStatus,
})
@@ -2645,15 +2659,6 @@ export const zAgentComposerDifyToolCandidateResponse = z.object({
tools_count: z.int().nullish(),
})
/**
* AgentKnowledgeDatasetConfig
*/
export const zAgentKnowledgeDatasetConfig = z.object({
description: z.string().nullish(),
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
})
/**
* CheckResultView
*
@@ -2783,30 +2788,6 @@ export const zAgentSoulHumanConfig = z.object({
tools: z.array(zAgentHumanToolConfig).optional(),
})
/**
* AgentKnowledgeQueryConfig
*/
export const zAgentKnowledgeQueryConfig = z.object({
query: z.string().nullish(),
score_threshold: z.number().gte(0).lte(1).nullish(),
score_threshold_enabled: z.boolean().nullish(),
top_k: z.int().gte(1).nullish(),
})
/**
* AgentKnowledgeQueryMode
*/
export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'])
/**
* AgentSoulKnowledgeConfig
*/
export const zAgentSoulKnowledgeConfig = z.object({
datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
query_config: zAgentKnowledgeQueryConfig.optional(),
query_mode: zAgentKnowledgeQueryMode.nullish(),
})
/**
* AgentMemoryArtifactConfig
*/
@@ -3018,6 +2999,27 @@ export const zAgentCliToolConfig = z.object({
tool_name: z.string().max(255).nullish(),
})
/**
* AgentComposerKnowledgeDatasetCandidateResponse
*/
export const zAgentComposerKnowledgeDatasetCandidateResponse = z.object({
description: z.string().nullish(),
id: z.string().max(255).nullish(),
missing: z.boolean().optional().default(false),
name: z.string().max(255).nullish(),
})
/**
* AgentComposerKnowledgeSetCandidateResponse
*/
export const zAgentComposerKnowledgeSetCandidateResponse = z.object({
datasets: z.array(zAgentComposerKnowledgeDatasetCandidateResponse).optional(),
description: z.string().nullish(),
id: z.string(),
missing_dataset_ids: z.array(z.string()).optional(),
name: z.string(),
})
/**
* AgentComposerSoulCandidatesResponse
*/
@@ -3025,7 +3027,7 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
cli_tools: z.array(zAgentCliToolConfig).optional(),
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
human_contacts: z.array(zAgentHumanContactConfig).optional(),
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
knowledge_sets: z.array(zAgentComposerKnowledgeSetCandidateResponse).optional(),
})
/**
@@ -3057,6 +3059,15 @@ export const zUserActionConfig = z.object({
title: z.string().max(100),
})
/**
* AgentKnowledgeDatasetConfig
*/
export const zAgentKnowledgeDatasetConfig = z.object({
description: z.string().nullish(),
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
})
/**
* AgentModelResponseFormatConfig
*/
@@ -3308,62 +3319,70 @@ export const zAgentSoulAppFeaturesConfig = z.object({
})
/**
* AgentSoulConfig
* AgentKnowledgeModelConfig
*/
export const zAgentSoulConfig = z.object({
app_features: zAgentSoulAppFeaturesConfig.optional(),
app_variables: z.array(zAppVariableConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
misc_legacy: zAgentSoulAppFeaturesConfig.optional(),
model: zAgentSoulModelConfig.nullish(),
prompt: zAgentSoulPromptConfig.optional(),
sandbox: zAgentSoulSandboxConfig.optional(),
schema_version: z.int().optional().default(1),
tools: zAgentSoulToolsConfig.optional(),
export const zAgentKnowledgeModelConfig = z.object({
completion_params: z.record(z.string(), z.unknown()).optional(),
mode: z.string().min(1).max(64),
name: z.string().min(1).max(255),
provider: z.string().min(1).max(255),
})
/**
* WorkflowAgentComposerResponse
* AgentKnowledgeQueryMode
*/
export const zWorkflowAgentComposerResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(),
agent: zAgentComposerAgentResponse.nullish(),
agent_soul: zAgentSoulConfig,
app_id: z.string().nullish(),
binding: zAgentComposerBindingResponse.nullish(),
effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(),
impact_summary: zAgentComposerImpactResponse.nullish(),
node_id: z.string().nullish(),
node_job: zWorkflowNodeJobConfig,
save_options: z.array(zComposerSaveStrategy),
soul_lock: zAgentComposerSoulLockResponse,
validation: zComposerValidationFindingsResponse.nullish(),
variant: z.literal('workflow'),
workflow_id: z.string().nullish(),
export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'])
/**
* 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.
*/
export const zAgentKnowledgeQueryConfig = z.object({
mode: zAgentKnowledgeQueryMode,
value: z.string().nullish(),
})
/**
* ComposerSavePayload
* AgentKnowledgeRerankingModelConfig
*/
export const zComposerSavePayload = z.object({
agent_soul: zAgentSoulConfig.nullish(),
binding: zComposerBindingPayload.nullish(),
client_revision_id: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().max(255).nullish(),
icon_background: z.string().max(255).nullish(),
icon_type: zAgentIconType.nullish(),
idempotency_key: z.string().nullish(),
new_agent_name: z.string().min(1).max(255).nullish(),
node_job: zWorkflowNodeJobConfig.nullish(),
role: z.string().max(255).nullish(),
save_strategy: zComposerSaveStrategy,
soul_lock: zComposerSoulLockPayload.optional(),
variant: zComposerVariant,
version_note: z.string().nullish(),
export const zAgentKnowledgeRerankingModelConfig = z.object({
model: z.string().min(1).max(255),
provider: z.string().min(1).max(255),
})
/**
* AgentKnowledgeWeightedScoreConfig
*/
export const zAgentKnowledgeWeightedScoreConfig = z.object({
keyword_setting: z.record(z.string(), z.unknown()).nullish(),
vector_setting: z.record(z.string(), z.unknown()).nullish(),
weight_type: z.string().max(64).nullish(),
})
/**
* 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.
*/
export const zAgentKnowledgeRetrievalConfig = z.object({
mode: z.enum(['multiple', 'single']),
model: zAgentKnowledgeModelConfig.nullish(),
reranking_enable: z.boolean().optional().default(true),
reranking_mode: z.string().optional().default('reranking_model'),
reranking_model: zAgentKnowledgeRerankingModelConfig.nullish(),
score_threshold: z.number().gte(0).lte(1).nullish(),
top_k: z.int().gte(1).nullish(),
weights: zAgentKnowledgeWeightedScoreConfig.nullish(),
})
/**
@@ -3487,6 +3506,151 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({
limit: z.int(),
})
/**
* AgentKnowledgeMetadataCondition
*/
export const zAgentKnowledgeMetadataCondition = z.object({
comparison_operator: z.enum([
'<',
'=',
'>',
'after',
'before',
'contains',
'empty',
'end with',
'in',
'is',
'is not',
'not contains',
'not empty',
'not in',
'start with',
'≠',
'≤',
'≥',
]),
name: z.string().min(1).max(255),
value: z.union([z.string(), z.array(z.string()), z.number()]).nullish(),
})
/**
* AgentKnowledgeMetadataConditions
*/
export const zAgentKnowledgeMetadataConditions = z.object({
conditions: z.array(zAgentKnowledgeMetadataCondition).optional(),
logical_operator: z.enum(['and', 'or']).optional().default('and'),
})
/**
* 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.
*/
export const zAgentKnowledgeMetadataFilteringConfig = z.object({
conditions: zAgentKnowledgeMetadataConditions.nullish(),
mode: z.enum(['automatic', 'disabled', 'manual']).optional().default('disabled'),
model_config: zAgentKnowledgeModelConfig.nullish(),
})
/**
* 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".
*/
export const zAgentKnowledgeSetConfig = z.object({
datasets: z.array(zAgentKnowledgeDatasetConfig),
description: z.string().nullish(),
id: z.string().min(1).max(255),
metadata_filtering: zAgentKnowledgeMetadataFilteringConfig.optional(),
name: z.string().min(1).max(255),
query: zAgentKnowledgeQueryConfig,
retrieval: zAgentKnowledgeRetrievalConfig,
})
/**
* 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.
*/
export const zAgentSoulKnowledgeConfig = z.object({
sets: z.array(zAgentKnowledgeSetConfig).optional(),
})
/**
* AgentSoulConfig
*/
export const zAgentSoulConfig = z.object({
app_features: zAgentSoulAppFeaturesConfig.optional(),
app_variables: z.array(zAppVariableConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
misc_legacy: zAgentSoulAppFeaturesConfig.optional(),
model: zAgentSoulModelConfig.nullish(),
prompt: zAgentSoulPromptConfig.optional(),
sandbox: zAgentSoulSandboxConfig.optional(),
schema_version: z.int().optional().default(1),
tools: zAgentSoulToolsConfig.optional(),
})
/**
* WorkflowAgentComposerResponse
*/
export const zWorkflowAgentComposerResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(),
agent: zAgentComposerAgentResponse.nullish(),
agent_soul: zAgentSoulConfig,
app_id: z.string().nullish(),
binding: zAgentComposerBindingResponse.nullish(),
effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(),
impact_summary: zAgentComposerImpactResponse.nullish(),
node_id: z.string().nullish(),
node_job: zWorkflowNodeJobConfig,
save_options: z.array(zComposerSaveStrategy),
soul_lock: zAgentComposerSoulLockResponse,
validation: zComposerValidationFindingsResponse.nullish(),
variant: z.literal('workflow'),
workflow_id: z.string().nullish(),
})
/**
* ComposerSavePayload
*/
export const zComposerSavePayload = z.object({
agent_soul: zAgentSoulConfig.nullish(),
binding: zComposerBindingPayload.nullish(),
client_revision_id: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().max(255).nullish(),
icon_background: z.string().max(255).nullish(),
icon_type: zAgentIconType.nullish(),
idempotency_key: z.string().nullish(),
new_agent_name: z.string().min(1).max(255).nullish(),
node_job: zWorkflowNodeJobConfig.nullish(),
role: z.string().max(255).nullish(),
save_strategy: zComposerSaveStrategy,
soul_lock: zComposerSoulLockPayload.optional(),
variant: zComposerVariant,
version_note: z.string().nullish(),
})
/**
* GeneratedAppResponse
*/
@@ -3535,22 +3699,32 @@ export const zAppPaginationWritable = z.object({
})
/**
* Site
* AppDetailSiteResponse
*/
export const zSiteWritable = z.object({
export const zAppDetailSiteResponseWritable = z.object({
access_token: z.string().nullish(),
app_base_url: z.string().nullish(),
chat_color_theme: z.string().nullish(),
chat_color_theme_inverted: z.boolean(),
chat_color_theme_inverted: z.boolean().nullish(),
code: z.string().nullish(),
copyright: z.string().nullish(),
created_at: z.int().nullish(),
created_by: z.string().nullish(),
custom_disclaimer: z.string().nullish(),
default_language: z.string(),
customize_domain: z.string().nullish(),
customize_token_strategy: z.string().nullish(),
default_language: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_type: z.union([z.string(), zIconType]).nullish(),
privacy_policy: z.string().nullish(),
show_workflow_steps: z.boolean(),
title: z.string(),
use_icon_as_answer_icon: z.boolean(),
prompt_public: z.boolean().nullish(),
show_workflow_steps: z.boolean().nullish(),
title: z.string().nullish(),
updated_at: z.int().nullish(),
updated_by: z.string().nullish(),
use_icon_as_answer_icon: z.boolean().nullish(),
})
/**
@@ -3577,7 +3751,7 @@ export const zAppDetailWithSiteWritable = z.object({
model_config: zModelConfig.nullish(),
name: z.string(),
permission_keys: z.array(z.string()).optional(),
site: zSiteWritable.nullish(),
site: zAppDetailSiteResponseWritable.nullish(),
tags: z.array(zTag).optional(),
tracing: zJsonValue.nullish(),
updated_at: z.int().nullish(),
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16" fill="none">
<path d="M7.5 2.2912C10.875 2.66428 13.5 5.52559 13.5 9V15.75H0V9C0 5.52559 2.62504 2.66428 6 2.2912V0H7.5V2.2912ZM6.75 12.75C8.82105 12.75 10.5 11.071 10.5 9C10.5 6.92895 8.82105 5.25 6.75 5.25C4.67893 5.25 3 6.92895 3 9C3 11.071 4.67893 12.75 6.75 12.75ZM6.75 11.25C5.50732 11.25 4.5 10.2427 4.5 9C4.5 7.75732 5.50732 6.75 6.75 6.75C7.99268 6.75 9 7.75732 9 9C9 10.2427 7.99268 11.25 6.75 11.25ZM6.75 9.75C7.16422 9.75 7.5 9.41422 7.5 9C7.5 8.58578 7.16422 8.25 6.75 8.25C6.33578 8.25 6 8.58578 6 9C6 9.41422 6.33578 9.75 6.75 9.75Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 664 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16" fill="none">
<path d="M7.5 2.2912C10.875 2.66428 13.5 5.52559 13.5 9V15.75H0V9C0 5.52559 2.62504 2.66428 6 2.2912V0H7.5V2.2912ZM12 14.25V9C12 6.10051 9.6495 3.75 6.75 3.75C3.85051 3.75 1.5 6.10051 1.5 9V14.25H12ZM6.75 12.75C4.67893 12.75 3 11.071 3 9C3 6.92895 4.67893 5.25 6.75 5.25C8.82105 5.25 10.5 6.92895 10.5 9C10.5 11.071 8.82105 12.75 6.75 12.75ZM6.75 11.25C7.99268 11.25 9 10.2427 9 9C9 7.75732 7.99268 6.75 6.75 6.75C5.50732 6.75 4.5 7.75732 4.5 9C4.5 10.2427 5.50732 11.25 6.75 11.25ZM6.75 9.75C6.33578 9.75 6 9.41422 6 9C6 8.58578 6.33578 8.25 6.75 8.25C7.16422 8.25 7.5 8.58578 7.5 9C7.5 9.41422 7.16422 9.75 6.75 9.75Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 749 B

@@ -1,6 +1,6 @@
{
"prefix": "custom-public",
"lastModified": 1781515983,
"lastModified": 1782215796,
"icons": {
"agent-building-blocks": {
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>"
@@ -1,14 +1,23 @@
{
"prefix": "custom-vender",
"lastModified": 1781515983,
"lastModified": 1782215796,
"icons": {
"agent-v2-access-point": {
"body": "<g fill=\"none\"><path d=\"M7.5 11.25C7.91421 11.25 8.25 11.5858 8.25 12V14.25C8.25 14.6642 7.91421 15 7.5 15C7.08579 15 6.75 14.6642 6.75 14.25V12C6.75 11.5858 7.08579 11.25 7.5 11.25Z\" fill=\"currentColor\"/><path d=\"M2.19653 2.19653C2.48937 1.90372 2.96418 1.90382 3.25708 2.19653L8.03027 6.96973C8.09162 7.03108 8.13966 7.10082 8.17529 7.1748C8.19164 7.20869 8.20587 7.24378 8.21704 7.28027C8.24638 7.37633 8.25641 7.477 8.24634 7.57617C8.23743 7.66451 8.21216 7.74788 8.17529 7.82446C8.13963 7.89868 8.09176 7.96874 8.03027 8.03027L3.25708 12.8035C2.96419 13.096 2.48932 13.0962 2.19653 12.8035C1.90394 12.5107 1.90405 12.0358 2.19653 11.7429L5.68945 8.25H0.75C0.335786 8.25 0 7.91421 0 7.5C0 7.08579 0.335786 6.75 0.75 6.75H5.68945L2.19653 3.25708C1.90389 2.96423 1.90388 2.48937 2.19653 2.19653Z\" fill=\"currentColor\"/><path d=\"M10.1521 10.1521C10.445 9.85921 10.9198 9.85921 11.2126 10.1521L12.8035 11.7429C13.096 12.0358 13.0962 12.5107 12.8035 12.8035C12.5107 13.0962 12.0358 13.096 11.7429 12.8035L10.1521 11.2126C9.85921 10.9198 9.85922 10.445 10.1521 10.1521Z\" fill=\"currentColor\"/><path d=\"M14.25 6.75C14.6642 6.75 15 7.08579 15 7.5C15 7.91421 14.6642 8.25 14.25 8.25H12C11.5858 8.25 11.25 7.91421 11.25 7.5C11.25 7.08579 11.5858 6.75 12 6.75H14.25Z\" fill=\"currentColor\"/><path d=\"M11.7422 2.19653C12.035 1.90387 12.5098 1.90406 12.8027 2.19653C13.0956 2.4894 13.0955 2.96419 12.8027 3.25708L11.2119 4.8479C10.919 5.14079 10.4443 5.1408 10.1514 4.8479C9.85883 4.55497 9.85858 4.08013 10.1514 3.78735L11.7422 2.19653Z\" fill=\"currentColor\"/><path d=\"M7.5 0C7.91421 0 8.25 0.335786 8.25 0.75V3C8.25 3.41421 7.91421 3.75 7.5 3.75C7.08579 3.75 6.75 3.41421 6.75 3V0.75C6.75 0.335786 7.08579 0 7.5 0Z\" fill=\"currentColor\"/></g>",
"width": 15,
"height": 15
},
"agent-v2-configure": {
"body": "<g fill=\"none\"><path d=\"M7.5 2.2912C10.875 2.66428 13.5 5.52559 13.5 9V15.75H0V9C0 5.52559 2.62504 2.66428 6 2.2912V0H7.5V2.2912ZM12 14.25V9C12 6.10051 9.6495 3.75 6.75 3.75C3.85051 3.75 1.5 6.10051 1.5 9V14.25H12ZM6.75 12.75C4.67893 12.75 3 11.071 3 9C3 6.92895 4.67893 5.25 6.75 5.25C8.82105 5.25 10.5 6.92895 10.5 9C10.5 11.071 8.82105 12.75 6.75 12.75ZM6.75 11.25C7.99268 11.25 9 10.2427 9 9C9 7.75732 7.99268 6.75 6.75 6.75C5.50732 6.75 4.5 7.75732 4.5 9C4.5 10.2427 5.50732 11.25 6.75 11.25ZM6.75 9.75C6.33578 9.75 6 9.41422 6 9C6 8.58578 6.33578 8.25 6.75 8.25C7.16422 8.25 7.5 8.58578 7.5 9C7.5 9.41422 7.16422 9.75 6.75 9.75Z\" fill=\"currentColor\"/></g>",
"width": 14
},
"agent-v2-configure-active": {
"body": "<g fill=\"none\"><path d=\"M7.5 2.2912C10.875 2.66428 13.5 5.52559 13.5 9V15.75H0V9C0 5.52559 2.62504 2.66428 6 2.2912V0H7.5V2.2912ZM6.75 12.75C8.82105 12.75 10.5 11.071 10.5 9C10.5 6.92895 8.82105 5.25 6.75 5.25C4.67893 5.25 3 6.92895 3 9C3 11.071 4.67893 12.75 6.75 12.75ZM6.75 11.25C5.50732 11.25 4.5 10.2427 4.5 9C4.5 7.75732 5.50732 6.75 6.75 6.75C7.99268 6.75 9 7.75732 9 9C9 10.2427 7.99268 11.25 6.75 11.25ZM6.75 9.75C7.16422 9.75 7.5 9.41422 7.5 9C7.5 8.58578 7.16422 8.25 6.75 8.25C6.33578 8.25 6 8.58578 6 9C6 9.41422 6.33578 9.75 6.75 9.75Z\" fill=\"currentColor\"/></g>",
"width": 14
},
"agent-v2-end-user-auth": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M12 7.33325C13.1046 7.33325 14 8.22865 14 9.33325C14 10.1403 13.5218 10.8356 12.8333 11.1516V11.9999L12.3333 12.4999L12.8333 12.9511V13.6666L12 14.3333L11.1667 13.6666V11.1516C10.4782 10.8356 10 10.1403 10 9.33325C10 8.22865 10.8954 7.33325 12 7.33325ZM12 8.66659C11.6318 8.66659 11.3333 8.96505 11.3333 9.33325C11.3333 9.70145 11.6318 9.99992 12 9.99992C12.3682 9.99992 12.6667 9.70145 12.6667 9.33325C12.6667 8.96505 12.3682 8.66659 12 8.66659Z\" fill=\"currentColor\"/><path d=\"M8 7.99992C8.2545 7.99992 8.50382 8.01506 8.7474 8.04484L8.58594 9.36841C8.39687 9.34527 8.20127 9.33325 8 9.33325C5.8465 9.33325 4.25915 10.7274 3.78646 12.6666H10V13.9999H2.26758L2.33594 13.2708C2.61081 10.3473 4.82817 7.99992 8 7.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8 1.33325C9.65687 1.33325 11 2.6764 11 4.33325C11 5.99011 9.65687 7.33325 8 7.33325C6.34315 7.33325 5 5.99011 5 4.33325C5 2.6764 6.34315 1.33325 8 1.33325ZM8 2.66659C7.07953 2.66659 6.33333 3.41278 6.33333 4.33325C6.33333 5.25373 7.07953 5.99992 8 5.99992C8.92047 5.99992 9.66667 5.25373 9.66667 4.33325C9.66667 3.41278 8.92047 2.66659 8 2.66659Z\" fill=\"currentColor\"/></g>"
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M12 7.33325C13.1046 7.33325 14 8.22865 14 9.33325C14 10.1403 13.5218 10.8356 12.8333 11.1516V11.9999L12.3333 12.4999L12.8333 12.9511V13.6666L12 14.3333L11.1667 13.6666V11.1516C10.4782 10.8356 10 10.1403 10 9.33325C10 8.22865 10.8954 7.33325 12 7.33325ZM12 8.66659C11.6318 8.66659 11.3333 8.96505 11.3333 9.33325C11.3333 9.70145 11.6318 9.99992 12 9.99992C12.3682 9.99992 12.6667 9.70145 12.6667 9.33325C12.6667 8.96505 12.3682 8.66659 12 8.66659Z\" fill=\"currentColor\"/><path d=\"M8 7.99992C8.2545 7.99992 8.50382 8.01506 8.7474 8.04484L8.58594 9.36841C8.39687 9.34527 8.20127 9.33325 8 9.33325C5.8465 9.33325 4.25915 10.7274 3.78646 12.6666H10V13.9999H2.26758L2.33594 13.2708C2.61081 10.3473 4.82817 7.99992 8 7.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8 1.33325C9.65687 1.33325 11 2.6764 11 4.33325C11 5.99011 9.65687 7.33325 8 7.33325C6.34315 7.33325 5 5.99011 5 4.33325C5 2.6764 6.34315 1.33325 8 1.33325ZM8 2.66659C7.07953 2.66659 6.33333 3.41278 6.33333 4.33325C6.33333 5.25373 7.07953 5.99992 8 5.99992C8.92047 5.99992 9.66667 5.25373 9.66667 4.33325C9.66667 3.41278 8.92047 2.66659 8 2.66659Z\" fill=\"currentColor\"/></g>",
"width": 16
},
"agent-v2-plan": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M17 0C17.5523 0 18 0.447715 18 1V6C18 6.55228 17.5523 7 17 7H12C11.4477 7 11 6.55228 11 6V4.5H6.94629C5.92438 4.50039 5.56101 5.85276 6.44531 6.36523L12.5576 9.90332C15.2116 11.4402 14.1206 15.4996 11.0537 15.5H7V17C7 17.5523 6.55228 18 6 18H1C0.447715 18 0 17.5523 0 17V12C0 11.4477 0.447715 11 1 11H6C6.55228 11 7 11.4477 7 12V13.5H11.0537C12.0756 13.4996 12.4394 12.1472 11.5557 11.6348L5.44336 8.09668C2.789 6.55983 3.87917 2.50039 6.94629 2.5H11V1C11 0.447715 11.4477 0 12 0H17ZM2 16H5V13H2V16ZM13 5H16V2H13V5Z\" fill=\"currentColor\"/></g>",
@@ -17,8 +26,8 @@
},
"agent-v2-prompt-insert": {
"body": "<g fill=\"none\"><path d=\"M2.91669 1.16669C1.95019 1.16669 1.16669 1.95019 1.16669 2.91669V11.0834C1.16669 12.0499 1.95019 12.8334 2.91669 12.8334H11.0834C12.0499 12.8334 12.8334 12.0499 12.8334 11.0834V2.91669C12.8334 1.95019 12.0499 1.16669 11.0834 1.16669H2.91669ZM2.33335 2.91669C2.33335 2.59452 2.59452 2.33335 2.91669 2.33335H11.0834C11.4055 2.33335 11.6667 2.59452 11.6667 2.91669V11.0834C11.6667 11.4055 11.4055 11.6667 11.0834 11.6667H2.91669C2.59452 11.6667 2.33335 11.4055 2.33335 11.0834V2.91669ZM5.67188 10.5L9.67186 3.50002H8.32815L4.32817 10.5H5.67188Z\" fill=\"currentColor\"/></g>",
"width": 14,
"height": 14
"height": 14,
"width": 14
},
"agent-v2-robot-3": {
"body": "<g fill=\"none\"><path d=\"M6.25 6.875C6.82523 6.875 7.29167 7.34128 7.29167 7.91667V9.16667C7.29167 9.74205 6.82523 10.2083 6.25 10.2083C5.67477 10.2083 5.20833 9.74205 5.20833 9.16667V7.91667C5.20833 7.34128 5.67477 6.875 6.25 6.875Z\" fill=\"currentColor\"/><path d=\"M10.4167 6.875C10.992 6.875 11.4583 7.34135 11.4583 7.91667V9.16667C11.4583 9.74199 10.992 10.2083 10.4167 10.2083C9.84135 10.2083 9.375 9.74199 9.375 9.16667V7.91667C9.375 7.34135 9.84135 6.875 10.4167 6.875Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.33333 0C9.13875 0 9.79167 0.652918 9.79167 1.45833C9.79167 2.02329 9.46964 2.51173 8.99984 2.75391V3.33822C9.38912 3.34279 9.77995 3.35006 10.175 3.36263C11.6983 3.41112 12.7377 3.42425 13.6401 3.90951C14.375 4.30477 15.0255 4.97655 15.3971 5.72347C15.5468 6.02442 15.6427 6.33532 15.7056 6.66667H15.8333C16.2936 6.66667 16.6667 7.03976 16.6667 7.5V10C16.6667 10.4602 16.2936 10.8333 15.8333 10.8333H15.8285C15.8235 11.2254 15.813 11.5735 15.7869 11.8831C15.7386 12.4571 15.6361 12.9628 15.3971 13.4432C15.0254 14.1901 14.3749 14.8619 13.6401 15.2572C12.7377 15.7424 11.6982 15.7556 10.175 15.804C8.93336 15.8436 7.73328 15.8436 6.4917 15.804C4.96843 15.7556 3.92896 15.7424 3.02653 15.2572C2.29178 14.8619 1.64121 14.1902 1.26953 13.4432C1.03058 12.9628 0.928072 12.4571 0.87972 11.8831C0.853642 11.5735 0.843216 11.2254 0.838216 10.8333H0.833333C0.373096 10.8333 0 10.4602 0 10V7.5C0 7.03976 0.373096 6.66667 0.833333 6.66667H0.9611C1.02392 6.33532 1.11984 6.02442 1.26953 5.72347C1.64119 4.97649 2.29177 4.30475 3.02653 3.90951C3.92895 3.42425 4.96837 3.41112 6.4917 3.36263C6.88671 3.35006 7.27754 3.34279 7.66683 3.33822V2.75391C7.19703 2.51173 6.875 2.02329 6.875 1.45833C6.875 0.652918 7.52792 0 8.33333 0ZM10.1213 5.02848C8.91522 4.9901 7.75142 4.9901 6.54541 5.02848C4.85908 5.08217 4.29323 5.12091 3.81592 5.3776C3.38476 5.60954 2.98015 6.02734 2.76204 6.46566C2.65217 6.68652 2.57959 6.96168 2.54069 7.4235C2.50069 7.89854 2.5 8.50363 2.5 9.37825V9.78841C2.5 10.663 2.50069 11.2681 2.54069 11.7432C2.57959 12.205 2.65215 12.4801 2.76204 12.701C2.98015 13.1393 3.38475 13.5571 3.81592 13.7891C4.29321 14.0458 4.85904 14.0845 6.54541 14.1382C7.75141 14.1766 8.91523 14.1766 10.1213 14.1382C11.8075 14.0845 12.3734 14.0458 12.8507 13.7891C13.2819 13.5572 13.6865 13.1394 13.9046 12.701C14.0145 12.4801 14.0871 12.205 14.126 11.7432C14.166 11.2681 14.1667 10.663 14.1667 9.78841V9.37825C14.1667 8.50363 14.166 7.89854 14.126 7.4235C14.0871 6.96168 14.0145 6.68652 13.9046 6.46566C13.6865 6.02729 13.2819 5.60951 12.8507 5.3776C12.3734 5.12091 11.8075 5.08217 10.1213 5.02848Z\" fill=\"currentColor\"/></g>",
@@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 326,
"total": 328,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",
@@ -14,11 +14,11 @@
},
"samples": [
"agent-v2-access-point",
"agent-v2-configure",
"agent-v2-configure-active",
"agent-v2-end-user-auth",
"agent-v2-plan",
"agent-v2-prompt-insert",
"agent-v2-robot-3",
"features-citations"
"agent-v2-prompt-insert"
],
"palette": false
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { canEmbedPath } from '@/proxy'
describe('proxy frame options', () => {
it('should allow embedded share routes', () => {
expect(canEmbedPath('/chatbot/token')).toBe(true)
expect(canEmbedPath('/workflow/token')).toBe(true)
expect(canEmbedPath('/completion/token')).toBe(true)
expect(canEmbedPath('/webapp-signin')).toBe(true)
expect(canEmbedPath('/agent/token')).toBe(true)
})
it('should deny non-embedded console routes by default', () => {
expect(canEmbedPath('/agents')).toBe(false)
expect(canEmbedPath('/agent-settings')).toBe(false)
expect(canEmbedPath('/agentic')).toBe(false)
expect(canEmbedPath('/roster/agent/agent-1/access')).toBe(false)
expect(canEmbedPath('/apps')).toBe(false)
})
})
@@ -47,10 +47,12 @@ const NavLink = ({
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const isCollapsed = mode !== 'expand'
const borderClassName = 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px]'
const linkClassName = cn(
borderClassName,
isActive
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
? 'border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
: 'border-transparent system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
isCollapsed ? 'flex size-8 items-center justify-center p-1.5' : 'flex h-8 items-center rounded-lg pr-1 pl-3',
'rounded-lg',
)
@@ -68,7 +70,9 @@ const NavLink = ({
type="button"
disabled
className={cn(
borderClassName,
'cursor-not-allowed rounded-lg system-sm-medium text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
'border-transparent',
isCollapsed ? 'flex size-8 items-center justify-center p-1.5' : 'flex h-8 items-center pr-1 pl-3',
)}
title={mode === 'collapse' ? name : ''}
@@ -285,6 +285,18 @@ describe('app-card-utils', () => {
expect(snippet).not.toContain('isDev: true')
})
it('should generate an agent embedded script route when requested', () => {
const snippet = getEmbeddedScriptSnippet({
url: 'https://example.com',
token: 'agent-token',
webAppRoute: 'agent',
primaryColor: '#1C64F2',
inputValues: {},
})
expect(snippet).toContain('routeSegment: \'agent\'')
})
it('should compress and encode base64 using CompressionStream when available', async () => {
const result = await compressAndEncodeBase64('hello')
expect(typeof result).toBe('string')
@@ -11,6 +11,7 @@ type OverviewCardType = 'api' | 'webapp'
export type OverviewOperationKey = 'launch' | 'embedded' | 'customize' | 'settings' | 'develop'
export type WorkflowLaunchInputValue = string | boolean
export type EmbeddedWebAppRoute = 'chatbot' | 'agent'
export type WorkflowHiddenStartVariable = Pick<
InputVar,
'default' | 'hide' | 'label' | 'max_length' | 'options' | 'required' | 'type' | 'variable'
@@ -156,12 +157,14 @@ ${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\
export const getEmbeddedScriptSnippet = ({
url,
token,
webAppRoute = 'chatbot',
primaryColor,
isTestEnv,
inputValues,
}: {
url: string
token: string
webAppRoute?: EmbeddedWebAppRoute
primaryColor: string
isTestEnv?: boolean
inputValues: Record<string, WorkflowLaunchInputValue>
@@ -174,6 +177,9 @@ export const getEmbeddedScriptSnippet = ({
: ''}${IS_CE_EDITION
? `,
baseUrl: '${url}${basePath}'`
: ''}${webAppRoute !== 'chatbot'
? `,
routeSegment: '${webAppRoute}'`
: ''},
inputs: ${getScriptInputsContent(inputValues)},
systemVariables: {
@@ -13,7 +13,8 @@ type IShareLinkProps = {
onClose: () => void
api_base_url: string
appId: string
mode: AppModeEnum
mode?: AppModeEnum
sourceCodeRepository?: 'webapp-conversation' | 'webapp-text-generator'
}
const StepNum: FC<{ children: React.ReactNode }> = ({ children }) => (
@@ -38,10 +39,12 @@ const CustomizeModal: FC<IShareLinkProps> = ({
appId,
api_base_url,
mode,
sourceCodeRepository,
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
const isChatApp = mode === AppModeEnum.CHAT || mode === AppModeEnum.ADVANCED_CHAT
const repository = sourceCodeRepository ?? (isChatApp ? 'webapp-conversation' : 'webapp-text-generator')
const apiDocLink = docLink('/use-dify/publish/developing-with-apis')
return (
@@ -67,7 +70,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
<div className="flex flex-col">
<div className="text-text-primary">{t(`${prefixCustomize}.way1.step1`, { ns: 'appOverview' })}</div>
<div className="mt-1 mb-2 text-xs text-text-tertiary">{t(`${prefixCustomize}.way1.step1Tip`, { ns: 'appOverview' })}</div>
<Button nativeButton={false} render={<a href={`https://github.com/langgenius/${isChatApp ? 'webapp-conversation' : 'webapp-text-generator'}`} target="_blank" rel="noopener noreferrer" />}>
<Button nativeButton={false} render={<a href={`https://github.com/langgenius/${repository}`} target="_blank" rel="noopener noreferrer" aria-label={t(`${prefixCustomize}.way1.step1Operation`, { ns: 'appOverview' })} />}>
<GithubIcon className="mr-2 text-text-secondary" />
{t(`${prefixCustomize}.way1.step1Operation`, { ns: 'appOverview' })}
</Button>
@@ -78,7 +81,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
<div className="flex flex-col">
<div className="text-text-primary">{t(`${prefixCustomize}.way1.step2`, { ns: 'appOverview' })}</div>
<div className="mt-1 mb-2 text-xs text-text-tertiary">{t(`${prefixCustomize}.way1.step2Tip`, { ns: 'appOverview' })}</div>
<Button nativeButton={false} render={<a href="https://vercel.com/docs/concepts/deployments/git/vercel-for-github" target="_blank" rel="noopener noreferrer" />}>
<Button nativeButton={false} render={<a href="https://vercel.com/docs/concepts/deployments/git/vercel-for-github" target="_blank" rel="noopener noreferrer" aria-label={t(`${prefixCustomize}.way1.step2Operation`, { ns: 'appOverview' })} />}>
<div className="mr-1.5 border-t-0 border-r-[7px] border-b-12 border-l-[7px] border-solid border-text-primary border-t-transparent border-r-transparent border-l-transparent"></div>
<span>{t(`${prefixCustomize}.way1.step2Operation`, { ns: 'appOverview' })}</span>
</Button>
@@ -114,7 +117,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
<p className="my-2 system-sm-medium text-text-secondary">{t(`${prefixCustomize}.way2.name`, { ns: 'appOverview' })}</p>
<Button
nativeButton={false}
render={<a href={apiDocLink} target="_blank" rel="noopener noreferrer" />}
render={<a href={apiDocLink} target="_blank" rel="noopener noreferrer" aria-label={t(`${prefixCustomize}.way2.operation`, { ns: 'appOverview' })} />}
className="mt-2"
>
<span className="text-sm text-text-secondary">{t(`${prefixCustomize}.way2.operation`, { ns: 'appOverview' })}</span>
@@ -1,13 +1,9 @@
import type { MutableRefObject } from 'react'
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils'
import type { EmbeddedWebAppRoute, WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils'
import type { SiteInfo } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiArrowDownSLine,
RiArrowRightSLine,
} from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -33,6 +29,7 @@ type Props = Readonly<{
onClose: () => void
accessToken?: string
appBaseUrl?: string
webAppRoute?: EmbeddedWebAppRoute
hiddenInputs?: WorkflowHiddenStartVariable[]
className?: string
}>
@@ -62,15 +59,17 @@ const getSerializedHiddenInputValue = (
const buildEmbeddedIframeUrl = async ({
appBaseUrl,
accessToken,
webAppRoute,
variables,
values,
}: {
appBaseUrl: string
accessToken: string
webAppRoute: EmbeddedWebAppRoute
variables: WorkflowHiddenStartVariable[]
values: Record<string, WorkflowLaunchInputValue>
}) => {
const iframeUrl = new URL(`${appBaseUrl}${basePath}/chatbot/${accessToken}`, window.location.origin)
const iframeUrl = new URL(`${appBaseUrl}${basePath}/${webAppRoute}/${accessToken}`, window.location.origin)
await Promise.all(variables.map(async (variable) => {
iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values)))
@@ -101,8 +100,9 @@ const EmbeddedContent = ({
siteInfo,
appBaseUrl,
accessToken,
webAppRoute = 'chatbot',
hiddenInputs,
}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> & Pick<Props, 'siteInfo' | 'hiddenInputs'>) => {
}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> & Pick<Props, 'siteInfo' | 'webAppRoute' | 'hiddenInputs'>) => {
const { t } = useTranslation()
const supportedHiddenInputs = useMemo<WorkflowHiddenStartVariable[]>(
() => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported),
@@ -122,6 +122,7 @@ const EmbeddedContent = ({
() => buildEmbeddedIframeUrl({
appBaseUrl,
accessToken,
webAppRoute,
variables: supportedHiddenInputs,
values: initialHiddenInputValues,
}),
@@ -143,6 +144,7 @@ const EmbeddedContent = ({
setPreviewIframeUrlPromise(buildEmbeddedIframeUrl({
appBaseUrl,
accessToken,
webAppRoute,
variables: supportedHiddenInputs,
values: nextHiddenInputValues,
}))
@@ -150,15 +152,17 @@ const EmbeddedContent = ({
const scriptsContent = useMemo(() => getEmbeddedScriptSnippet({
url: appBaseUrl,
token: accessToken,
webAppRoute,
primaryColor: themeBuilder.theme?.primaryColor ?? '#1C64F2',
isTestEnv,
inputValues: hiddenInputValues,
}), [accessToken, appBaseUrl, hiddenInputValues, isTestEnv, themeBuilder.theme?.primaryColor])
}), [accessToken, appBaseUrl, hiddenInputValues, isTestEnv, themeBuilder.theme?.primaryColor, webAppRoute])
const onClickCopy = async () => {
const latestIframeUrl = await buildEmbeddedIframeUrl({
appBaseUrl,
accessToken,
webAppRoute,
variables: supportedHiddenInputs,
values: hiddenInputValues,
})
@@ -211,8 +215,8 @@ const EmbeddedContent = ({
</div>
</div>
{hiddenInputsCollapsed
? <RiArrowRightSLine className="size-4 shrink-0 text-text-tertiary" />
: <RiArrowDownSLine className="size-4 shrink-0 text-text-tertiary" />}
? <span aria-hidden className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" />
: <span aria-hidden className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-tertiary" />}
</button>
{!hiddenInputsCollapsed && (
<div className="max-h-72 space-y-4 overflow-y-auto border-t-[0.5px] border-divider-subtle px-4 py-4">
@@ -307,7 +311,7 @@ const EmbeddedContent = ({
)
}
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenInputs, className }: Props) => {
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, webAppRoute = 'chatbot', hiddenInputs, className }: Props) => {
const { t } = useTranslation()
return (
@@ -327,10 +331,11 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenIn
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{isShow && (
<EmbeddedContent
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${JSON.stringify(hiddenInputs ?? [])}`}
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${webAppRoute}:${JSON.stringify(hiddenInputs ?? [])}`}
siteInfo={siteInfo}
appBaseUrl={appBaseUrl ?? ''}
accessToken={accessToken ?? ''}
webAppRoute={webAppRoute}
hiddenInputs={hiddenInputs}
/>
)}
@@ -231,15 +231,15 @@ describe('SettingsModal', () => {
expect(mockOnClose).toHaveBeenCalled()
})
it('should collapse the expanded settings section immediately when closing', () => {
it('should keep one show-more trigger while toggling the advanced section', () => {
renderSettingsModal()
expect(screen.getAllByText('appOverview.overview.appInfo.settings.more.entry')).toHaveLength(1)
fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
expect(screen.getAllByText('appOverview.overview.appInfo.settings.more.entry')).toHaveLength(1)
expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument()
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(screen.getByText('appOverview.overview.appInfo.settings.more.entry')).toBeInTheDocument()
})
it('should reset local form state when the controlled dialog reopens', () => {
@@ -276,6 +276,7 @@ describe('SettingsModal', () => {
)
expect(screen.getByText('appOverview.overview.appInfo.settings.more.entry')).toBeInTheDocument()
expect(screen.queryByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).not.toBeInTheDocument()
})
it('should open the pricing modal from the copyright upgrade badge for sandbox plans', async () => {
+246 -222
View File
@@ -1,12 +1,15 @@
'use client'
import type { FC } from 'react'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { AppDetailResponse } from '@/models/app'
import type { AppIconType, AppSSO, Language } from '@/types/app'
import type { AppIconType, Language, SiteConfig } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { CollapsiblePanel, CollapsibleRoot, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldDescription, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Input } from '@langgenius/dify-ui/input'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
@@ -29,13 +32,38 @@ import { AppModeEnum } from '@/types/app'
type ISettingsModalProps = {
isChat: boolean
appInfo: AppDetailResponse & Partial<AppSSO>
appInfo: SettingsAppInfo
isShow: boolean
defaultValue?: string
onClose: () => void
onSave?: (params: ConfigParams) => Promise<void>
}
type SettingsSiteInfo = Pick<
SiteConfig,
| 'title'
| 'description'
| 'default_language'
| 'chat_color_theme'
| 'chat_color_theme_inverted'
| 'copyright'
| 'privacy_policy'
| 'custom_disclaimer'
| 'icon_type'
| 'icon'
| 'icon_background'
| 'icon_url'
| 'show_workflow_steps'
| 'use_icon_as_answer_icon'
>
export type SettingsAppInfo = {
id: string
mode: AppModeEnum
enable_sso?: boolean
site: SettingsSiteInfo
}
export type ConfigParams = {
title: string
description: string
@@ -124,7 +152,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
onClose,
onSave,
}) => {
const [isShowMore, setIsShowMore] = useState(false)
const { default_language } = appInfo.site
const nextInputInfo = createInputInfo(appInfo)
const nextAppIcon = createAppIcon(appInfo)
@@ -164,17 +191,11 @@ const SettingsModal: FC<ISettingsModalProps> = ({
setInputInfo(nextInputInfo)
setLanguage(default_language)
setAppIcon(nextAppIcon)
setIsShowMore(false)
setPreviousSettingsResetKey(settingsResetKey)
}
}
const onHide = () => {
onClose()
setIsShowMore(false)
}
const onClickSave = async () => {
const handleFormSubmit = async () => {
if (!inputInfo.title) {
toast.error(t('newApp.nameNotEmpty', { ns: 'app' }))
return
@@ -231,7 +252,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
}
await onSave?.(params)
setSaveLoading(false)
onHide()
onClose()
}
const onChange = (field: string) => {
@@ -252,8 +273,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
return (
<>
<Dialog open={isShow} onOpenChange={open => !open && onHide()}>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[520px] flex-col overflow-hidden! p-0!">
<Dialog open={isShow} onOpenChange={open => !open && onClose()} disablePointerDismissal>
<DialogContent className="grid max-h-[calc(100dvh-2rem)] w-[520px] grid-rows-[auto_minmax(0,1fr)] overflow-hidden p-0">
{/* header */}
<div className="shrink-0 pt-5 pr-5 pb-3 pl-6">
<div className="flex items-center gap-1">
@@ -264,224 +285,227 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<span>{t(`${prefixSettings}.modalTip`, { ns: 'appOverview' })}</span>
</div>
</div>
{/* form body */}
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain px-6 py-3">
{/* name & icon */}
<div className="flex gap-4">
<div className="grow">
<div className={cn('mb-1 py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}</div>
<Input
className="w-full"
value={inputInfo.title}
onChange={onChange('title')}
placeholder={t('appNamePlaceholder', { ns: 'app' }) || ''}
<Form className="grid min-h-0 grid-rows-[minmax(0,1fr)_auto]" onFormSubmit={handleFormSubmit}>
{/* form body */}
<ScrollArea
className="relative min-h-0"
slotClassNames={{
viewport: 'overscroll-contain',
content: 'min-w-0 space-y-5 px-6 py-3',
}}
>
{/* name & icon */}
<div className="flex gap-4">
<FieldRoot name="title" className="grow">
<FieldLabel>{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}</FieldLabel>
<FieldControl
value={inputInfo.title}
onValueChange={value => setInputInfo(item => ({ ...item, title: value }))}
placeholder={t('appNamePlaceholder', { ns: 'app' }) || ''}
/>
</FieldRoot>
<AppIcon
size="xxl"
onClick={() => { setShowAppIconPicker(true) }}
className="mt-2 cursor-pointer"
iconType={appIcon.type}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
/>
</div>
<AppIcon
size="xxl"
onClick={() => { setShowAppIconPicker(true) }}
className="mt-2 cursor-pointer"
iconType={appIcon.type}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
/>
</div>
{/* description */}
<div className="relative">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
<Textarea
aria-label={t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}
className="mt-1"
value={inputInfo.desc}
onValueChange={onDesChange}
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
/>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
</div>
<Divider className="my-0 h-px" />
{/* answer icon */}
{isChat && (
<div className="w-full">
<div className="flex items-center justify-between">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t('answerIcon.title', { ns: 'app' })}</div>
<Switch
checked={inputInfo.use_icon_as_answer_icon}
onCheckedChange={v => setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v })}
/>
</div>
<p className="pb-0.5 body-xs-regular text-text-tertiary">{t('answerIcon.description', { ns: 'app' })}</p>
</div>
)}
{/* language */}
<div className="flex items-center">
<div className={cn('grow py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
<Select
value={selectedLanguage?.value ?? null}
onValueChange={handleLanguageChange}
>
<SelectTrigger
aria-label={t(`${prefixSettings}.language`, { ns: 'appOverview' })}
size="large"
className="w-[200px]"
>
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent>
{LANGUAGE_OPTIONS.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* theme color */}
{isChat && (
<div className="flex items-center">
<div className="grow">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.chatColorTheme`, { ns: 'appOverview' })}</div>
<div className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.chatColorThemeDesc`, { ns: 'appOverview' })}</div>
</div>
<div className="shrink-0">
<Input
className="mb-1 w-[200px]"
value={inputInfo.chatColorTheme ?? ''}
onChange={onChange('chatColorTheme')}
placeholder="E.g #A020F0"
/>
<div className="flex items-center justify-between">
<p className={cn('body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</p>
<Switch checked={inputInfo.chatColorThemeInverted} onCheckedChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch>
{/* description */}
<FieldRoot name="description">
<FieldLabel>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</FieldLabel>
<Textarea
value={inputInfo.desc}
onValueChange={onDesChange}
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
/>
<FieldDescription>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</FieldDescription>
</FieldRoot>
<Divider className="my-0 h-px" />
{/* answer icon */}
{isChat && (
<FieldRoot name="use_icon_as_answer_icon" className="w-full">
<div className="flex items-center justify-between gap-3">
<FieldLabel>{t('answerIcon.title', { ns: 'app' })}</FieldLabel>
<Switch
checked={inputInfo.use_icon_as_answer_icon}
onCheckedChange={v => setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v })}
/>
</div>
<FieldDescription>{t('answerIcon.description', { ns: 'app' })}</FieldDescription>
</FieldRoot>
)}
{/* language */}
<div className="flex items-center">
<div className={cn('grow py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
<Select
value={selectedLanguage?.value ?? null}
onValueChange={handleLanguageChange}
>
<SelectTrigger
aria-label={t(`${prefixSettings}.language`, { ns: 'appOverview' })}
size="medium"
className="w-[200px]"
>
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent>
{LANGUAGE_OPTIONS.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* theme color */}
{isChat && (
<div className="flex items-center">
<div className="grow">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.chatColorTheme`, { ns: 'appOverview' })}</div>
<div className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.chatColorThemeDesc`, { ns: 'appOverview' })}</div>
</div>
<FieldRoot name="chat_color_theme" className="w-[200px] shrink-0">
<FieldControl
className="mb-1"
value={inputInfo.chatColorTheme ?? ''}
onValueChange={value => setInputInfo(item => ({ ...item, chatColorTheme: value }))}
placeholder="E.g #A020F0"
/>
<div className="flex items-center justify-between gap-2 body-xs-regular text-text-tertiary">
<span>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</span>
<Switch checked={inputInfo.chatColorThemeInverted} onCheckedChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch>
</div>
</FieldRoot>
</div>
</div>
)}
{/* workflow detail */}
<div className="w-full">
<div className="flex items-center justify-between">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.workflow.subTitle`, { ns: 'appOverview' })}</div>
<Switch
disabled={!(appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT)}
checked={inputInfo.show_workflow_steps}
onCheckedChange={v => setInputInfo({ ...inputInfo, show_workflow_steps: v })}
/>
</div>
<p className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.workflow.showDesc`, { ns: 'appOverview' })}</p>
</div>
{/* more settings switch */}
<Divider className="my-0 h-px" />
{!isShowMore && (
<div className="flex cursor-pointer items-center" onClick={() => setIsShowMore(true)}>
<div className="grow">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.entry`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>
{t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' })}
{' '}
&
{' '}
{t(`${prefixSettings}.more.privacyPolicyPlaceholder`, { ns: 'appOverview' })}
</p>
)}
{/* workflow detail */}
<FieldRoot name="show_workflow_steps" className="w-full">
<div className="flex items-center justify-between gap-3">
<FieldLabel>{t(`${prefixSettings}.workflow.subTitle`, { ns: 'appOverview' })}</FieldLabel>
<Switch
disabled={!(appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT)}
checked={inputInfo.show_workflow_steps}
onCheckedChange={v => setInputInfo({ ...inputInfo, show_workflow_steps: v })}
/>
</div>
<span aria-hidden="true" className="ml-1 i-ri-arrow-right-s-line size-4 shrink-0 text-text-secondary" />
</div>
)}
{/* more settings */}
{isShowMore && (
<>
{/* copyright */}
<div className="w-full">
<div className="flex items-center">
<div className="flex grow items-center">
<div className={cn('mr-1 py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
{/* upgrade button */}
{showUpgradeAction && (
<div className="h-[18px] select-none">
<PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}>
<span aria-hidden="true" className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
<div className="system-xs-medium">
<span className="p-1">
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
</span>
<FieldDescription>{t(`${prefixSettings}.workflow.showDesc`, { ns: 'appOverview' })}</FieldDescription>
</FieldRoot>
<CollapsibleRoot>
<Divider className="my-0 h-px" />
<CollapsibleTrigger className="-mx-2 mt-2 min-h-11 px-2 py-1.5 hover:not-data-disabled:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-inset data-panel-open:text-text-secondary">
<div className="min-w-0 grow">
<div className="system-sm-semibold text-text-secondary">{t(`${prefixSettings}.more.entry`, { ns: 'appOverview' })}</div>
<p className="truncate body-xs-regular text-text-tertiary">
{t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' })}
{' '}
&
{' '}
{t(`${prefixSettings}.more.privacyPolicyPlaceholder`, { ns: 'appOverview' })}
</p>
</div>
<span aria-hidden="true" className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-secondary transition-transform group-data-panel-open:rotate-90" />
</CollapsibleTrigger>
<CollapsiblePanel>
<div className="space-y-5 pt-5">
{/* copyright */}
<div className="w-full">
<div className="flex items-center">
<div className="flex grow items-center">
<div className={cn('mr-1 py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
{/* upgrade button */}
{showUpgradeAction && (
<div className="h-[18px] select-none">
<PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}>
<span aria-hidden="true" className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
<div className="system-xs-medium">
<span className="p-1">
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
</span>
</div>
</PremiumBadgeButton>
</div>
</PremiumBadgeButton>
)}
</div>
{webappCopyrightEnabled
? (
<Switch
checked={inputInfo.copyrightSwitchValue}
onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
/>
)
: (
<Tooltip>
<TooltipTrigger
render={(
<div>
<Switch
disabled
checked={inputInfo.copyrightSwitchValue}
onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
/>
</div>
)}
/>
<TooltipContent className="w-[180px]">
{t(`${prefixSettings}.more.copyrightTooltip`, { ns: 'appOverview' })}
</TooltipContent>
</Tooltip>
)}
</div>
<p className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
{inputInfo.copyrightSwitchValue && (
<Input
className="mt-2 h-10"
value={inputInfo.copyright}
onChange={onChange('copyright')}
placeholder={t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' }) as string}
/>
)}
</div>
{webappCopyrightEnabled
? (
<Switch
checked={inputInfo.copyrightSwitchValue}
onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
/>
)
: (
<Tooltip>
<TooltipTrigger
render={(
<div>
<Switch
disabled
checked={inputInfo.copyrightSwitchValue}
onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
/>
</div>
)}
/>
<TooltipContent className="w-[180px]">
{t(`${prefixSettings}.more.copyrightTooltip`, { ns: 'appOverview' })}
</TooltipContent>
</Tooltip>
)}
{/* privacy policy */}
<div className="w-full">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.privacyPolicy`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>
<Trans
i18nKey={`${prefixSettings}.more.privacyPolicyTip`}
ns="appOverview"
components={{ privacyPolicyLink: <Link href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer" className="text-text-accent" /> }}
/>
</p>
<Input
className="mt-1"
value={inputInfo.privacyPolicy}
onChange={onChange('privacyPolicy')}
placeholder={t(`${prefixSettings}.more.privacyPolicyPlaceholder`, { ns: 'appOverview' }) as string}
/>
</div>
{/* custom disclaimer */}
<div className="w-full">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
<Textarea
aria-label={t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}
className="mt-1"
value={inputInfo.customDisclaimer}
onValueChange={value => setInputInfo(item => ({ ...item, customDisclaimer: value }))}
placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`, { ns: 'appOverview' }) as string}
/>
</div>
</div>
<p className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
{inputInfo.copyrightSwitchValue && (
<Input
className="mt-2 h-10"
value={inputInfo.copyright}
onChange={onChange('copyright')}
placeholder={t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' }) as string}
/>
)}
</div>
{/* privacy policy */}
<div className="w-full">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.privacyPolicy`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>
<Trans
i18nKey={`${prefixSettings}.more.privacyPolicyTip`}
ns="appOverview"
components={{ privacyPolicyLink: <Link href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer" className="text-text-accent" /> }}
/>
</p>
<Input
className="mt-1"
value={inputInfo.privacyPolicy}
onChange={onChange('privacyPolicy')}
placeholder={t(`${prefixSettings}.more.privacyPolicyPlaceholder`, { ns: 'appOverview' }) as string}
/>
</div>
{/* custom disclaimer */}
<div className="w-full">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
<Textarea
aria-label={t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}
className="mt-1"
value={inputInfo.customDisclaimer}
onValueChange={value => setInputInfo(item => ({ ...item, customDisclaimer: value }))}
placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`, { ns: 'appOverview' }) as string}
/>
</div>
</>
)}
</div>
{/* footer */}
<div className="flex shrink-0 justify-end p-6 pt-5">
<Button className="mr-2" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" onClick={onClickSave} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</CollapsiblePanel>
</CollapsibleRoot>
</ScrollArea>
{/* footer */}
<div className="flex shrink-0 justify-end p-6 pt-5">
<Button type="button" className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button type="submit" variant="primary" loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</Form>
</DialogContent>
</Dialog>
<AppIconPicker
@@ -57,6 +57,9 @@ const ChatWrapper = () => {
} = useChatWithHistoryContext()
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const timezone = appSourceType === AppSourceType.webApp
? Intl.DateTimeFormat().resolvedOptions().timeZone
: undefined
// Semantic variable for better code readability
const isHistoryConversation = !!currentConversationId
@@ -91,6 +94,8 @@ const ChatWrapper = () => {
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
clearChatList,
setClearChatList,
undefined,
{ timezone },
)
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
const inputDisabled = useMemo(() => {
@@ -6,6 +6,10 @@ import { useParams, usePathname } from '@/next/navigation'
import { sseGet, ssePost } from '@/service/base'
import { useChat } from '../hooks'
const useTimestampMock = vi.hoisted(() =>
vi.fn(() => ({ formatTime: vi.fn().mockReturnValue('10:00 AM') })),
)
vi.mock('@/service/base', () => ({
sseGet: vi.fn(),
ssePost: vi.fn(),
@@ -31,7 +35,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({ formatTime: vi.fn().mockReturnValue('10:00 AM') }),
default: useTimestampMock,
}))
vi.mock('@/next/navigation', () => ({
@@ -91,6 +95,21 @@ describe('useChat', () => {
expect(result.current.suggestedQuestions).toEqual([])
})
it('should pass timestamp options to timestamp formatter', () => {
renderHook(() => useChat(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ timezone: 'UTC' },
))
expect(useTimestampMock).toHaveBeenCalledWith({ timezone: 'UTC' })
})
it('should initialize with opening statement and suggested questions', () => {
const config = {
opening_statement: 'Hello {{name}}',
@@ -20,9 +20,14 @@ import { useCheckInputsForms } from '../check-input-forms-hooks'
import { useTextAreaHeight } from './hooks'
import Operation from './operation'
type AudioRecorderWithPermission = typeof Recorder & {
getPermission: () => Promise<void>
}
type ChatInputAreaProps = {
readonly?: boolean
botName?: string
placeholder?: string
showFeatureBar?: boolean
showFileUpload?: boolean
featureBarReadonly?: boolean
@@ -31,7 +36,7 @@ type ChatInputAreaProps = {
visionConfig?: FileUpload
speechToTextConfig?: EnableType
onSend?: OnSend
inputs?: Record<string, any>
inputs?: Record<string, unknown>
inputsForm?: InputForm[]
theme?: Theme | null
isResponding?: boolean
@@ -44,7 +49,7 @@ type ChatInputAreaProps = {
*/
sendOnEnter?: boolean
}
const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => {
const ChatInputArea = ({ readonly, botName, placeholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => {
const { t } = useTranslation()
const { wrapperRef, textareaRef, textValueRef, holdSpaceRef, handleTextareaResize, isMultipleLine } = useTextAreaHeight()
const [query, setQuery] = useState('')
@@ -130,7 +135,7 @@ const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, feat
}
}
const handleShowVoiceInput = useCallback(() => {
(Recorder as any).getPermission().then(() => {
;(Recorder as AudioRecorderWithPermission).getPermission().then(() => {
setShowVoiceInput(true)
}, () => {
toast.error(t('voiceInput.notAllow', { ns: 'common' }))
@@ -147,7 +152,28 @@ const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, feat
<div ref={textValueRef} className="pointer-events-none invisible absolute size-auto p-1 body-lg-regular leading-6 whitespace-pre">
{query}
</div>
<Textarea ref={ref => textareaRef.current = ref as any} className={cn('w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-text-primary outline-hidden')} placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')} autoFocus minRows={1} value={query} onChange={e => handleQueryChange(e.target.value)} onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onPaste={handleClipboardPasteFile} onDragEnter={handleDragFileEnter} onDragLeave={handleDragFileLeave} onDragOver={handleDragFileOver} onDrop={handleDropFile} readOnly={readonly} />
<Textarea
ref={(ref) => {
textareaRef.current = ref ?? undefined
}}
className={cn('w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-text-primary outline-hidden')}
placeholder={placeholder ?? decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')}
// Existing chat behavior focuses the composer as soon as it opens.
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
minRows={1}
value={query}
onChange={e => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onPaste={handleClipboardPasteFile}
onDragEnter={handleDragFileEnter}
onDragLeave={handleDragFileLeave}
onDragOver={handleDragFileOver}
onDrop={handleDropFile}
readOnly={readonly}
/>
</div>
{!isMultipleLine && operation}
</div>
+6 -1
View File
@@ -55,6 +55,10 @@ type SendCallback = {
isPublicAPI?: boolean
}
type UseChatOptions = {
timezone?: string
}
export const useChat = (
config?: ChatConfig,
formSettings?: {
@@ -66,9 +70,10 @@ export const useChat = (
clearChatList?: boolean,
clearChatListCallback?: (state: boolean) => void,
initialConversationId?: string,
options: UseChatOptions = {},
) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const { formatTime } = useTimestamp({ timezone: options.timezone })
const conversationIdRef = useRef(initialConversationId ?? '')
const initialConversationIdRef = useRef(initialConversationId ?? '')
const hasStopRespondedRef = useRef(false)
+11 -4
View File
@@ -13,6 +13,8 @@ import type {
import type { HumanInputFormSubmitData } from './answer/human-input-content/type'
import type { InputForm } from './type'
import type { Emoji } from '@/app/components/tools/types'
import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
import type { Node } from '@/app/components/workflow/types'
import type { AppData } from '@/models/share'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@@ -39,7 +41,7 @@ export type ChatProps = {
onStopResponding?: () => void
noChatInput?: boolean
onSend?: OnSend
inputs?: Record<string, any>
inputs?: Record<string, unknown>
inputsForm?: InputForm[]
onRegenerate?: OnRegenerate
chatContainerClassName?: string
@@ -68,11 +70,13 @@ export type ChatProps = {
onFeatureBarClick?: (state: boolean) => void
noSpacing?: boolean
inputDisabled?: boolean
inputPlaceholder?: string
inputPlaceholderBotName?: string
sidebarCollapseState?: boolean
hideAvatar?: boolean
sendOnEnter?: boolean
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => any
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
}
const Chat: FC<ChatProps> = ({
@@ -114,6 +118,8 @@ const Chat: FC<ChatProps> = ({
onFeatureBarClick,
noSpacing,
inputDisabled,
inputPlaceholder,
inputPlaceholderBotName,
sidebarCollapseState,
hideAvatar,
sendOnEnter,
@@ -180,7 +186,7 @@ const Chat: FC<ChatProps> = ({
appData={appData}
key={item.id}
item={item}
question={chatList[index - 1]?.content!}
question={chatList[index - 1]?.content ?? ''}
index={index}
config={config}
answerIcon={answerIcon}
@@ -240,7 +246,8 @@ const Chat: FC<ChatProps> = ({
{
!noChatInput && (
<ChatInputArea
botName={appData?.site?.title || 'Bot'}
botName={inputPlaceholderBotName || appData?.site?.title || 'Bot'}
placeholder={inputPlaceholder}
disabled={inputDisabled}
showFeatureBar={showFeatureBar}
showFileUpload={showFileUpload}
@@ -81,6 +81,9 @@ const ChatWrapper = () => {
opening_statement: currentConversationItem?.introduction || (config as any).opening_statement,
} as ChatConfig
}, [appParams, currentConversationItem?.introduction])
const timezone = appSourceType === AppSourceType.webApp
? Intl.DateTimeFormat().resolvedOptions().timeZone
: undefined
const {
chatList,
handleSend,
@@ -98,6 +101,8 @@ const ChatWrapper = () => {
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
clearChatList,
setClearChatList,
undefined,
{ timezone },
)
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
const inputDisabled = useMemo(() => {
@@ -384,6 +384,7 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: /common.menus.roster/ })).toHaveAttribute('href', '/roster')
expect(screen.getByRole('link', { name: /common.menus.roster common.menus.status/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets')
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/integrations/model-provider')
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/marketplace')
@@ -1,5 +1,6 @@
'use client'
import type { ReactNode } from 'react'
import type { MainNavItem } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import Link from '@/next/link'
@@ -17,11 +18,13 @@ const NavIcon = ({
type MainNavLinkProps = {
item: MainNavItem
pathname: string
children?: ReactNode
}
const MainNavLink = ({
item,
pathname,
children,
}: MainNavLinkProps) => {
const activated = item.active(pathname)
@@ -38,7 +41,8 @@ const MainNavLink = ({
>
<NavIcon icon={item.icon} className="group-aria-[current=page]:hidden" />
<NavIcon icon={item.activeIcon} className="hidden group-aria-[current=page]:block" />
<span className="truncate group-aria-[current=page]:text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]">{item.label}</span>
<span className="min-w-0 truncate group-aria-[current=page]:text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]">{item.label}</span>
{children}
</Link>
)
}
+11 -1
View File
@@ -12,6 +12,7 @@ import AppDetailTop from '@/app/components/app-sidebar/app-detail-top'
import DatasetDetailSection from '@/app/components/app-sidebar/dataset-detail-section'
import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top'
import { useStore as useAppStore } from '@/app/components/app/store'
import Badge from '@/app/components/base/badge'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
import { useAppContext } from '@/context/app-context'
@@ -299,7 +300,16 @@ const MainNav = ({
<>
<nav className="isolate flex flex-col gap-px p-2">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname} />
<MainNavLink key={item.href} item={item} pathname={pathname}>
{item.href === '/roster' && (
<Badge
size="xs"
variant="dimm"
text={t('menus.status', { ns: 'common' })}
className="ml-auto shrink-0"
/>
)}
</MainNavLink>
))}
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
@@ -270,6 +270,71 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
expect(callbacks.onSettled).toHaveBeenCalled()
})
it('should keep pending inline Agent v2 nodes in draft without incomplete bindings', async () => {
reactFlowState = {
...reactFlowState,
edges: [
{ id: 'edge-1', source: 'n1', target: 'pending-agent', data: { sourceType: BlockEnum.Start, targetType: BlockEnum.Agent } },
{ id: 'temp-edge', source: 'temp-node', target: 'pending-agent', data: {} },
],
}
mockGetNodes.mockReturnValue([
{ id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } },
{
id: 'pending-agent',
position: { x: 1, y: 1 },
data: {
type: BlockEnum.Agent,
title: 'Agent',
desc: '',
agent_node_kind: 'dify_agent',
version: '2',
agent_binding: {
binding_type: 'inline_agent',
},
_isTempNode: true,
_openInlineAgentPanel: true,
selected: true,
},
},
{ id: 'temp-node', position: { x: 2, y: 2 }, data: { type: BlockEnum.Answer, _isTempNode: true } },
])
const { result } = renderUseNodesSyncDraft()
await act(async () => {
await result.current.doSyncWorkflowDraft(false)
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({
params: expect.objectContaining({
graph: expect.objectContaining({
nodes: [
{ id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } },
{
id: 'pending-agent',
position: { x: 1, y: 1 },
data: {
type: BlockEnum.Agent,
title: 'Agent',
desc: '',
agent_node_kind: 'dify_agent',
version: '2',
agent_binding: {
binding_type: 'inline_agent',
},
selected: true,
},
},
],
edges: [
{ id: 'edge-1', source: 'n1', target: 'pending-agent', data: { sourceType: BlockEnum.Start, targetType: BlockEnum.Agent } },
],
}),
}),
}))
})
it('should post workflow draft with keepalive when the page closes', () => {
reactFlowState = {
...reactFlowState,
@@ -8,6 +8,7 @@ import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
import { useNodesReadOnly, useNodesReadOnlyByCanEdit } from '@/app/components/workflow/hooks/use-workflow'
import { isAgentV2NodeData, needsInlineAgentBindingCreation } from '@/app/components/workflow/nodes/agent-v2/types'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { API_PREFIX } from '@/config'
@@ -33,10 +34,26 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
transform,
} = store.getState()
const allNodes = getNodes()
const nodes = allNodes.filter(node => !node.data?._isTempNode && node.data?.type !== BlockEnum.StartPlaceholder)
const nodes = allNodes.filter((node) => {
if (node.data?.type === BlockEnum.StartPlaceholder)
return false
if (!node.data?._isTempNode)
return true
return isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data)
})
const skippedNodeIds = new Set(
allNodes
.filter(node => node.data?._isTempNode || node.data?.type === BlockEnum.StartPlaceholder)
.filter((node) => {
if (node.data?.type === BlockEnum.StartPlaceholder)
return true
if (!node.data?._isTempNode)
return false
return !(isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data))
})
.map(node => node.id),
)
const [x, y, zoom] = transform
@@ -159,9 +176,10 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
setDraftUpdatedAt(res.updated_at)
callback?.onSuccess?.()
}
catch (error: any) {
if (error && error.json && !error.bodyUsed) {
error.json().then((err: any) => {
catch (error: unknown) {
const responseError = error as { bodyUsed?: boolean, json?: () => Promise<{ code?: string }> }
if (responseError.json && !responseError.bodyUsed) {
responseError.json().then((err) => {
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError)
handleRefreshWorkflowDraft(true)
})
@@ -294,6 +294,7 @@ describe('CandidateNodeMain', () => {
agent_binding: {
binding_type: 'inline_agent',
},
selected: true,
_isTempNode: true,
}),
}),
@@ -307,6 +308,7 @@ describe('CandidateNodeMain', () => {
agent_id: 'inline-agent-1',
current_snapshot_id: 'inline-snapshot-1',
},
selected: true,
}),
}),
]))
@@ -53,12 +53,18 @@ const CandidateNodeMain: FC<Props> = ({
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
const shouldCreateInlineAgentBinding = isAgentV2NodeData(candidateNode.data) && needsInlineAgentBindingCreation(candidateNode.data)
const newNodes = produce(nodes, (draft) => {
if (shouldCreateInlineAgentBinding) {
draft.forEach((node) => {
node.data.selected = false
})
}
draft.push({
...candidateNode,
data: {
...candidateNode.data,
_isCandidate: false,
_isTempNode: shouldCreateInlineAgentBinding ? true : candidateNode.data._isTempNode,
selected: shouldCreateInlineAgentBinding ? true : candidateNode.data.selected,
},
position: {
x,
@@ -89,6 +95,8 @@ const CandidateNodeMain: FC<Props> = ({
}
if (shouldCreateInlineAgentBinding) {
workflowStore.getState().setOpenInlineAgentPanelNodeId(candidateNode.id)
handleSyncWorkflowDraft(true, true)
createInlineAgentBinding(candidateNode.id, {
onError: () => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
@@ -105,7 +113,6 @@ const CandidateNodeMain: FC<Props> = ({
delete node.data._isTempNode
}
}))
workflowStore.getState().setOpenInlineAgentPanelNodeId(candidateNode.id)
handleSyncWorkflowDraft(true, true)
},
})
@@ -193,6 +193,8 @@ export const useNodesInteractions = () => {
const createInlineAgentBindingForNode = useCallback((nodeId: string, options?: {
onError?: () => void
}) => {
workflowStore.getState().setOpenInlineAgentPanelNodeId(nodeId)
handleSyncWorkflowDraft(true, true)
createInlineAgentBinding(nodeId, {
onError: () => {
options?.onError?.()
@@ -208,7 +210,6 @@ export const useNodesInteractions = () => {
delete node.data._isTempNode
}
}))
workflowStore.getState().setOpenInlineAgentPanelNodeId(nodeId)
handleSyncWorkflowDraft(true, true)
},
})
@@ -156,6 +156,16 @@ describe('useCreateInlineAgentBinding', () => {
agent_id: 'inline-agent-1',
current_snapshot_id: 'inline-snapshot-1',
}))
expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual(expect.objectContaining({
agent_soul: expect.objectContaining({
schema_version: 1,
}),
binding: expect.objectContaining({
binding_type: 'inline_agent',
agent_id: 'inline-agent-1',
current_snapshot_id: 'inline-snapshot-1',
}),
}))
})
it('creates inline agent with a model-less initial soul before the default model loads', async () => {
@@ -212,6 +222,61 @@ describe('useCreateInlineAgentBinding', () => {
current_snapshot_id: 'inline-snapshot-1',
}))
})
it('finishes inline creation after the caller component unmounts', async () => {
let resolveComposerState!: (value: Awaited<ReturnType<typeof mockComposerMutationFn>>) => void
mockComposerMutationFn.mockImplementationOnce(async _variables =>
new Promise((resolve) => {
resolveComposerState = resolve as typeof resolveComposerState
}),
)
const onSuccess = vi.fn()
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
retry: false,
},
},
})
const { result, unmount } = renderWorkflowHook(() => useCreateInlineAgentBinding(), {
queryClient,
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
},
})
act(() => {
void result.current.createInlineAgentBinding('node-1', { onSuccess })
})
await waitFor(() => expect(mockComposerMutationFn).toHaveBeenCalled())
unmount()
resolveComposerState({
agent_soul: {
schema_version: 1,
},
binding: {
binding_type: 'inline_agent',
agent_id: 'inline-agent-1',
current_snapshot_id: 'inline-snapshot-1',
},
variables: {},
})
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith({
binding_type: 'inline_agent',
agent_id: 'inline-agent-1',
current_snapshot_id: 'inline-snapshot-1',
}))
expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual(expect.objectContaining({
agent_soul: expect.objectContaining({
schema_version: 1,
}),
}))
})
})
describe('useWorkflowInlineAgentConfigureSync', () => {
@@ -123,9 +123,9 @@ describe('agent/node', () => {
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.name')).toHaveClass('system-xs-regular', 'text-text-secondary')
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.type')).toHaveClass('system-2xs-regular', 'text-text-tertiary')
const robotIcon = container.querySelector('.i-custom-vender-agent-v2-robot-3')
expect(robotIcon).toHaveClass('size-5')
expect(robotIcon?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
const configureIcon = container.querySelector('.i-custom-vender-agent-v2-configure')
expect(configureIcon).toHaveClass('h-3.5', 'w-3')
expect(configureIcon?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
})
it('renders the fixed inline setup name when workflow composer state is loaded', () => {
@@ -14,6 +14,8 @@ const {
mockInsertNodes,
mockOrchestrateDrawerPanelProps,
mockPromptEditorProps,
mockCopyFromRosterMutate,
mockCopyFromRosterState,
mockCreateInlineAgentBinding,
mockSetInputs,
mockStoreState,
@@ -27,16 +29,21 @@ const {
mockHandleNodeDataUpdateWithSyncDraft: vi.fn((_payload, options) => options?.callback?.onSuccess?.()),
mockInsertNodes: vi.fn(),
mockOrchestrateDrawerPanelProps: [] as Array<{
agentId: string
agentId?: string
inlineComposerState?: unknown
isInline: boolean
nodeId: string
open: boolean
}>,
mockPromptEditorProps: [] as PromptEditorProps[],
mockCopyFromRosterMutate: vi.fn(),
mockCopyFromRosterState: {
isPending: false,
},
mockCreateInlineAgentBinding: vi.fn(),
mockSetInputs: vi.fn(),
mockStoreState: {
appId: 'app-1',
openInlineAgentPanelNodeId: undefined as string | undefined,
setOpenInlineAgentPanelNodeId: vi.fn(),
},
@@ -80,6 +87,18 @@ vi.mock('@lexical/react/LexicalComposerContext', () => ({
}],
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: () => ({
isPending: mockCopyFromRosterState.isPending,
mutate: mockCopyFromRosterMutate,
}),
}
})
vi.mock('lexical', async (importOriginal) => {
const actual = await importOriginal<typeof import('lexical')>()
@@ -150,7 +169,7 @@ vi.mock('../hooks', () => ({
vi.mock('../components/agent-orchestrate-drawer-panel', () => ({
AgentOrchestrateDrawerPanel: (props: {
agentId: string
agentId?: string
appId?: string
inlineComposerState?: unknown
isInline: boolean
@@ -165,6 +184,41 @@ vi.mock('../components/agent-orchestrate-drawer-panel', () => ({
},
}))
vi.mock('../components/save-inline-agent-to-roster-dialog', () => ({
SaveInlineAgentToRosterDialog: ({
open,
onSaved,
}: {
open: boolean
onSaved: (binding: {
agent_id?: string | null
binding_type: 'inline_agent' | 'roster_agent'
current_snapshot_id?: string | null
id: string
node_id: string
workflow_id: string
}) => void
}) => open
? (
<div role="dialog" aria-label="save-inline-agent-to-roster">
<button
type="button"
onClick={() => onSaved({
id: 'binding-1',
binding_type: 'roster_agent',
agent_id: 'saved-roster-agent',
current_snapshot_id: 'saved-snapshot',
workflow_id: 'workflow-1',
node_id: 'agent-node',
})}
>
Save inline agent to roster
</button>
</div>
)
: null,
}))
vi.mock('../../_base/hooks/use-available-var-list', () => ({
default: () => ({
availableVars: [{
@@ -215,7 +269,32 @@ describe('agent/panel', () => {
vi.clearAllMocks()
mockPromptEditorProps.length = 0
mockOrchestrateDrawerPanelProps.length = 0
mockStoreState.appId = 'app-1'
mockStoreState.openInlineAgentPanelNodeId = undefined
mockCopyFromRosterState.isPending = false
mockCopyFromRosterMutate.mockImplementation((_variables, options?: {
onSuccess?: (composerState: {
binding: {
agent_id: string
binding_type: 'inline_agent'
current_snapshot_id: string
id: string
node_id: string
workflow_id: string
}
}) => void
}) => {
options?.onSuccess?.({
binding: {
id: 'binding-1',
binding_type: 'inline_agent',
agent_id: 'inline-copy-agent',
current_snapshot_id: 'inline-copy-snapshot',
workflow_id: 'workflow-1',
node_id: 'agent-node',
},
})
})
mockCreateInlineAgentBinding.mockImplementation(() => {})
mockUseNodeCrud.mockImplementation((_id: string, data: AgentV2NodeType) => ({
inputs: data,
@@ -304,6 +383,52 @@ describe('agent/panel', () => {
expect(screen.queryByRole('dialog', { name: 'Nadia' })).not.toBeInTheDocument()
})
it('copies a roster agent from the drawer into an inline agent for this node', () => {
render(
<AgentV2Panel
id="agent-node"
data={createData()}
panelProps={panelProps}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }))
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' }))
expect(mockCopyFromRosterMutate).toHaveBeenCalledWith(
{
params: {
app_id: 'app-1',
node_id: 'agent-node',
},
body: {
source_agent_id: 'agent-1',
},
},
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node')
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
{
id: 'agent-node',
data: expect.objectContaining({
agent_binding: {
binding_type: 'inline_agent',
agent_id: 'inline-copy-agent',
current_snapshot_id: 'inline-copy-snapshot',
},
_openInlineAgentPanel: true,
}),
},
expect.objectContaining({
sync: true,
notRefreshWhenSyncError: true,
}),
)
})
it('renders a required roster state when no roster agent is selected', () => {
render(
<AgentV2Panel
@@ -321,6 +446,7 @@ describe('agent/panel', () => {
})
it('renders a pending inline agent state while the binding is being created', () => {
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
const { container } = render(
<AgentV2Panel
id="agent-node"
@@ -337,6 +463,16 @@ describe('agent/panel', () => {
expect(screen.queryByText(/^workflow\.errorMsg\.fieldRequired/)).not.toBeInTheDocument()
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change' })).toBeDisabled()
expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.agent.roster.editInConsole')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.agent.roster.makeCopy')).not.toBeInTheDocument()
expect(mockOrchestrateDrawerPanelProps.at(-1)).toMatchObject({
agentId: undefined,
isInline: true,
nodeId: 'agent-node',
open: true,
})
expect(screen.getByText('workflow.nodes.agent.task.label')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.agent.outputVars.text')).toBeInTheDocument()
expect(container.querySelector('[inert]')).toBeInTheDocument()
@@ -366,11 +502,11 @@ describe('agent/panel', () => {
expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.name')).toBeInTheDocument()
expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument()
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
const panelRobotIcon = panel.querySelector('.i-custom-vender-agent-v2-robot-3')
expect(container.querySelector('.i-custom-vender-agent-v2-robot-3')).toHaveClass('size-5')
expect(container.querySelector('.i-custom-vender-agent-v2-robot-3')?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
expect(panelRobotIcon).toHaveClass('size-5')
expect(panelRobotIcon?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
const panelConfigureIcon = panel.querySelector('.i-custom-vender-agent-v2-configure')
expect(container.querySelector('.i-custom-vender-agent-v2-configure')).toHaveClass('h-3.5', 'w-3')
expect(container.querySelector('.i-custom-vender-agent-v2-configure')?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
expect(panelConfigureIcon).toHaveClass('h-3.5', 'w-3')
expect(panelConfigureIcon?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
expect(screen.queryByText('workflow.nodes.agent.roster.inlineSetup.title')).not.toBeInTheDocument()
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.description')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
@@ -421,7 +557,7 @@ describe('agent/panel', () => {
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
expect(panel).toBeInTheDocument()
expect(panel.querySelector('header')).not.toHaveClass('h-[108px]')
expect(panel.querySelector('.i-custom-vender-agent-v2-robot-3')?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
expect(panel.querySelector('.i-custom-vender-agent-v2-configure')?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
expect(within(panel).queryByText('Workflow Agent 1')).not.toBeInTheDocument()
expect(within(panel).getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument()
expect(within(panel).queryByText('workflow.nodes.agent.roster.inlineSetup.title')).not.toBeInTheDocument()
@@ -431,6 +567,45 @@ describe('agent/panel', () => {
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
})
it('opens save-to-roster action from the inline drawer menu and rebinds to the saved roster agent', () => {
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
render(
<AgentV2Panel
id="agent-node"
data={createData({
agent_binding: {
binding_type: 'inline_agent',
agent_id: 'inline-agent-1',
current_snapshot_id: 'snapshot-1',
},
})}
panelProps={panelProps}
/>,
)
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
fireEvent.click(within(panel).getByRole('button', { name: 'workflow.nodes.agent.roster.more' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'agentV2.roster.saveToRoster' }))
fireEvent.click(screen.getByRole('button', { name: 'Save inline agent to roster' }))
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith(undefined)
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
{
id: 'agent-node',
data: expect.objectContaining({
agent_binding: {
binding_type: 'roster_agent',
agent_id: 'saved-roster-agent',
},
}),
},
expect.objectContaining({
sync: true,
notRefreshWhenSyncError: true,
}),
)
})
it('does not show start from scratch for an existing inline agent binding', () => {
render(
<AgentV2Panel
@@ -471,7 +646,9 @@ describe('agent/panel', () => {
expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1')
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })).toBeInTheDocument()
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
expect(panel).toBeInTheDocument()
expect(within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.more' })).not.toBeInTheDocument()
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
expect(mockOrchestrateDrawerPanelProps.at(-1)).toMatchObject({
agentId: 'inline-agent-1',
@@ -570,10 +747,30 @@ describe('agent/panel', () => {
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change' }))
fireEvent.click(screen.getByRole('button', { name: 'Start from Scratch' }))
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node')
expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith('agent-node', expect.objectContaining({
onSuccess: expect.any(Function),
}))
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node')
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
{
id: 'agent-node',
data: expect.objectContaining({
agent_binding: {
binding_type: 'inline_agent',
},
agent_task: 'Keep this task',
agent_declared_outputs: [{
name: 'summary',
type: 'string',
}],
_openInlineAgentPanel: true,
}),
},
expect.objectContaining({
sync: true,
notRefreshWhenSyncError: true,
}),
)
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
{
id: 'agent-node',
@@ -0,0 +1,174 @@
import type { AgentComposerAgentResponse } from '@dify/contracts/api/console/apps/types.gen'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SaveInlineAgentToRosterDialog } from '../save-inline-agent-to-roster-dialog'
const mutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
}))
const toastMock = vi.hoisted(() => ({
success: vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({
isPending: mutationMock.isPending,
mutate: mutationMock.mutate,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
__esModule: true,
default: ({
initialEmoji,
onSelect,
open,
}: {
initialEmoji?: { icon: string, background: string }
onSelect: (payload: { type: 'emoji', icon: string, background: string }) => void
open: boolean
}) => open
? (
<div>
<span>{`${initialEmoji?.icon}:${initialEmoji?.background}`}</span>
<button
type="button"
onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })}
>
Select brain icon
</button>
</div>
)
: null,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
byAppId: {
workflows: {
draft: {
nodes: {
byNodeId: {
agentComposer: {
saveToRoster: {
post: {
mutationOptions: vi.fn(() => ({})),
},
},
},
},
},
},
},
},
},
},
}))
const inlineAgent: AgentComposerAgentResponse = {
active_config_snapshot_id: 'snapshot-1',
description: 'Drafts tender clarifications.',
icon: '🤖',
icon_background: '#F5F3FF',
icon_type: 'emoji',
id: 'inline-agent-1',
name: 'Inline Tender Agent',
role: 'Tender Analyst',
scope: 'workflow_only',
status: 'active',
}
const renderDialog = () => {
const onOpenChange = vi.fn()
const onSaved = vi.fn()
render(
<SaveInlineAgentToRosterDialog
appId="app-1"
formKey={1}
initialAgent={inlineAgent}
nodeId="node-1"
open
onOpenChange={onOpenChange}
onSaved={onSaved}
/>,
)
return { onOpenChange, onSaved }
}
describe('SaveInlineAgentToRosterDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mutationMock.isPending = false
})
it('initializes form fields from the inline agent metadata', async () => {
const user = userEvent.setup()
renderDialog()
const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' })
expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' })).toHaveValue('Inline Tender Agent')
expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' })).toHaveValue('Tender Analyst')
expect(within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder')).toHaveValue('Drafts tender clarifications.')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
expect(mutationMock.mutate).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
node_id: 'node-1',
},
body: {
variant: 'workflow',
save_strategy: 'save_to_roster',
new_agent_name: 'Inline Tender Agent',
description: 'Drafts tender clarifications.',
role: 'Tender Analyst',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1]
expect(mutationOptions).not.toHaveProperty('onError')
})
it('initializes the icon picker from the inline agent and submits changed icon fields', async () => {
const user = userEvent.setup()
renderDialog()
const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' })
await user.click(within(dialog).getByRole('button', { name: 'agentV2.roster.saveToRosterForm.changeIcon' }))
expect(screen.getByText('🤖:#F5F3FF')).toBeInTheDocument()
await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' }))
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
expect(mutationMock.mutate).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
node_id: 'node-1',
},
body: {
variant: 'workflow',
save_strategy: 'save_to_roster',
new_agent_name: 'Inline Tender Agent',
description: 'Drafts tender clarifications.',
role: 'Tender Analyst',
icon_type: 'emoji',
icon: '🧠',
icon_background: '#E0F2FE',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
})
})
@@ -15,7 +15,7 @@ import { consoleQuery } from '@/service/client'
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
type AgentOrchestrateDrawerPanelProps = {
agentId: string
agentId?: string
appId?: string
inlineComposerState?: WorkflowAgentComposerResponse
isInline: boolean
@@ -40,7 +40,7 @@ function AgentOrchestrateDrawerPanelContent({
open,
}: AgentOrchestrateDrawerPanelProps) {
const rosterComposerQuery = useQuery(consoleQuery.agent.byAgentId.composer.get.queryOptions({
input: open && !isInline
input: open && !isInline && agentId
? {
params: {
agent_id: agentId,
@@ -62,12 +62,12 @@ function AgentOrchestrateDrawerPanelContent({
})
useHydrateAgentSoulConfigDraft({
agentId: isInline ? `${nodeId}:${agentId}` : agentId,
agentId: isInline ? `${nodeId}:${agentId ?? 'pending'}` : agentId ?? nodeId,
activeVersionId: activeConfigSnapshot?.id,
config: agentSoulConfig as AgentSoulConfig | undefined,
})
if (!agentSoulConfig) {
if (!agentId || !agentSoulConfig) {
return (
<div className="flex h-full min-h-80 items-center justify-center bg-components-panel-bg">
<Loading type="app" />
@@ -0,0 +1,184 @@
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { AgentOutputVariables } from '../index'
const editorLabel = 'workflow.nodes.agent.outputVars.editorLabel'
const nameLabel = 'workflow.nodes.agent.outputVars.nameLabel'
const confirmLabel = 'workflow.nodes.agent.outputVars.confirm'
async function expandOutputVars(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.outputVars' }))
}
function getAddButton(name: string) {
return screen.getByRole('button', { name: `common.operation.add ${name}` })
}
function getEditButton(name: string) {
return screen.getByRole('button', {
name: `workflow.nodes.agent.outputVars.edit:{"name":"${name}"}`,
})
}
async function confirmEditorName(user: ReturnType<typeof userEvent.setup>, name: string) {
const editor = screen.getByRole('form', { name: editorLabel })
const nameInput = within(editor).getByLabelText(nameLabel)
await user.clear(nameInput)
await user.type(nameInput, name)
await user.click(within(editor).getByRole('button', { name: confirmLabel }))
}
describe('AgentOutputVariables', () => {
it('should add an object child without opening the parent editor', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const outputs: DeclaredOutputConfig[] = [{
name: 'profile',
type: 'object',
required: true,
description: 'User profile',
}]
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
await expandOutputVars(user)
await user.click(getAddButton('profile'))
expect(screen.getAllByRole('form', { name: editorLabel })).toHaveLength(1)
expect(screen.getByText('profile')).toBeInTheDocument()
await confirmEditorName(user, 'email')
expect(onChange).toHaveBeenCalledWith([{
name: 'profile',
type: 'object',
required: true,
description: 'User profile',
children: [{
name: 'email',
type: 'string',
required: false,
}],
}])
})
it('should append children to array object items', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const outputs: DeclaredOutputConfig[] = [{
name: 'addresses',
type: 'array',
required: false,
array_item: {
type: 'object',
},
}]
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
await expandOutputVars(user)
await user.click(getAddButton('addresses'))
await confirmEditorName(user, 'city')
expect(onChange).toHaveBeenCalledWith([{
name: 'addresses',
type: 'array',
required: false,
array_item: {
type: 'object',
children: [{
name: 'city',
type: 'string',
required: false,
}],
},
}])
})
it('should add nested children under the selected object child', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const outputs: DeclaredOutputConfig[] = [{
name: 'profile',
type: 'object',
required: true,
children: [{
name: 'contact',
type: 'object',
required: true,
}],
}]
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
await expandOutputVars(user)
await user.click(getAddButton('contact'))
await confirmEditorName(user, 'email')
expect(onChange).toHaveBeenCalledWith([{
name: 'profile',
type: 'object',
required: true,
children: [{
name: 'contact',
type: 'object',
required: true,
children: [{
name: 'email',
type: 'string',
required: false,
}],
}],
}])
})
it('should edit only the selected nested child', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const outputs: DeclaredOutputConfig[] = [{
name: 'profile',
type: 'object',
required: true,
children: [{
name: 'contact',
type: 'object',
required: true,
children: [{
name: 'email',
type: 'string',
required: true,
description: 'Primary email',
}],
}],
}]
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
await expandOutputVars(user)
await user.click(getEditButton('email'))
expect(screen.getAllByRole('form', { name: editorLabel })).toHaveLength(1)
await confirmEditorName(user, 'work_email')
expect(onChange).toHaveBeenCalledWith([{
name: 'profile',
type: 'object',
required: true,
children: [{
name: 'contact',
type: 'object',
required: true,
children: [{
name: 'work_email',
type: 'string',
required: true,
description: 'Primary email',
}],
}],
}])
})
})
@@ -6,6 +6,7 @@ import {
} from '../utils'
const createDraft = (overrides: Partial<OutputDraft> = {}): OutputDraft => ({
children: [],
defaultValue: '',
description: '',
name: 'summary',
@@ -54,4 +55,49 @@ describe('agent output variables utils', () => {
type: 'array[file]',
}))).toBe('nodes.agent.outputVars.defaultValueFileUnsupported')
})
it('should preserve object children when building an output', () => {
expect(createOutputFromDraft(createDraft({
children: [{
name: 'email',
type: 'string',
required: true,
description: 'User email',
}],
name: 'profile',
type: 'object',
}))).toMatchObject({
name: 'profile',
type: 'object',
children: [{
name: 'email',
type: 'string',
required: true,
description: 'User email',
}],
})
})
it('should preserve array object item children when building an output', () => {
expect(createOutputFromDraft(createDraft({
children: [{
name: 'city',
type: 'string',
required: false,
}],
name: 'addresses',
type: 'array[object]',
}))).toMatchObject({
name: 'addresses',
type: 'array',
array_item: {
type: 'object',
children: [{
name: 'city',
type: 'string',
required: false,
}],
},
})
})
})
@@ -1,5 +1,5 @@
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
import type { EditingState, OutputDraft } from './utils'
import type { EditableOutputConfig, EditingState, OutputDraft } from './utils'
import { Button } from '@langgenius/dify-ui/button'
import { CollapsiblePanel, CollapsibleRoot, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
@@ -38,21 +38,25 @@ function ConfirmHotkeyHint() {
export function OutputEditCard({
existingOutputs,
editingIndex,
allowDefaultValue = true,
state,
onCancel,
onConfirm,
}: {
existingOutputs: DeclaredOutputConfig[]
existingOutputs: EditableOutputConfig[]
editingIndex?: number
allowDefaultValue?: boolean
state: EditingState
onCancel: () => void
onConfirm: (output: DeclaredOutputConfig, index?: number) => void
onConfirm: (output: DeclaredOutputConfig, state: EditingState) => void
}) {
const { t } = useTranslation()
const nameErrorId = useId()
const editorRef = useRef<HTMLDivElement>(null)
const [draft, setDraft] = useState(state.draft)
const trimmedName = draft.name.trim()
const duplicateName = existingOutputs.some((output, index) => output.name === trimmedName && index !== state.index)
const duplicateName = existingOutputs.some((output, index) => output.name === trimmedName && index !== editingIndex)
const nameInvalid = !!trimmedName && !OUTPUT_NAME_PATTERN.test(trimmedName)
const hasNameError = duplicateName || nameInvalid
const defaultValueErrorKey = getDefaultValueErrorKey(draft)
@@ -63,7 +67,7 @@ export function OutputEditCard({
function handleConfirm() {
if (confirmDisabled)
return
onConfirm(createOutputFromDraft(draft), state.index)
onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state)
}
useHotkey(CONFIRM_HOTKEY, handleConfirm, { target: editorRef, ignoreInputs: false })
useHotkey('Escape', onCancel, { target: editorRef, ignoreInputs: false })
@@ -134,36 +138,38 @@ export function OutputEditCard({
/>
</FieldRoot>
</div>
<CollapsibleRoot>
<CollapsibleTrigger className="h-8 min-h-8 justify-start gap-x-0.5 rounded-none border-y border-divider-subtle pr-2 pl-2.5 system-xs-regular text-text-tertiary hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-tertiary focus-visible:bg-state-base-hover focus-visible:ring-inset data-panel-open:text-text-tertiary">
<span
aria-hidden="true"
className="i-ri-arrow-down-double-line size-3 transition-transform duration-100 ease-out group-data-panel-open:rotate-180 motion-reduce:transition-none"
/>
{t('nodes.agent.outputVars.showAdvancedOptions', { ns: 'workflow' })}
</CollapsibleTrigger>
<CollapsiblePanel className="border-t border-divider-subtle">
<div className="px-3 py-2">
<FieldRoot name="defaultValue" className="gap-1">
<FieldLabel className="py-0 system-xs-medium text-text-secondary">
{t('nodes.agent.outputVars.defaultValueLabel', { ns: 'workflow' })}
</FieldLabel>
<Textarea
size="small"
value={draft.defaultValue}
placeholder={t('nodes.agent.outputVars.defaultValuePlaceholder', { ns: 'workflow' })}
className="mt-1 min-h-6"
onValueChange={defaultValue => updateDraft({ defaultValue })}
/>
{defaultValueErrorKey && (
<FieldError match className="py-0 system-xs-regular text-text-destructive">
{t(defaultValueErrorKey, { ns: 'workflow' })}
</FieldError>
)}
</FieldRoot>
</div>
</CollapsiblePanel>
</CollapsibleRoot>
{allowDefaultValue && (
<CollapsibleRoot>
<CollapsibleTrigger className="h-8 min-h-8 justify-start gap-x-0.5 rounded-none border-y border-divider-subtle pr-2 pl-2.5 system-xs-regular text-text-tertiary hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-tertiary focus-visible:bg-state-base-hover focus-visible:ring-inset data-panel-open:text-text-tertiary">
<span
aria-hidden="true"
className="i-ri-arrow-down-double-line size-3 transition-transform duration-100 ease-out group-data-panel-open:rotate-180 motion-reduce:transition-none"
/>
{t('nodes.agent.outputVars.showAdvancedOptions', { ns: 'workflow' })}
</CollapsibleTrigger>
<CollapsiblePanel className="border-t border-divider-subtle">
<div className="px-3 py-2">
<FieldRoot name="defaultValue" className="gap-1">
<FieldLabel className="py-0 system-xs-medium text-text-secondary">
{t('nodes.agent.outputVars.defaultValueLabel', { ns: 'workflow' })}
</FieldLabel>
<Textarea
size="small"
value={draft.defaultValue}
placeholder={t('nodes.agent.outputVars.defaultValuePlaceholder', { ns: 'workflow' })}
className="mt-1 min-h-6"
onValueChange={defaultValue => updateDraft({ defaultValue })}
/>
{defaultValueErrorKey && (
<FieldError match className="py-0 system-xs-regular text-text-destructive">
{t(defaultValueErrorKey, { ns: 'workflow' })}
</FieldError>
)}
</FieldRoot>
</div>
</CollapsiblePanel>
</CollapsibleRoot>
)}
<div className="flex h-12 items-center justify-end gap-x-2 px-3">
<Button type="button" size="small" variant="secondary" onClick={onCancel}>
{t('operation.cancel', { ns: 'common' })}
@@ -1,5 +1,6 @@
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
import type { AgentOutputVariablesProps, EditingState } from './utils'
import type { ReactNode } from 'react'
import type { AgentOutputVariablesProps, DeclaredOutputChildConfig, EditableOutputConfig, EditingState } from './utils'
import { Button } from '@langgenius/dify-ui/button'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -7,21 +8,30 @@ import Divider from '@/app/components/base/divider'
import OutputVars from '../../../_base/components/output-vars'
import { OutputEditCard } from './edit-card'
import {
canOutputHaveChildren,
createDraft,
deleteOutputChildAtPath,
getOutputChildren,
getOutputChildrenAtPath,
getOutputDescription,
getOutputDisplayType,
getOutputTypeOptionValue,
insertOutputChildAtPath,
isDefaultOutput,
toDeclaredOutputChild,
updateOutputChildAtPath,
} from './utils'
function OutputRow({
output,
editable,
onAddChild,
onDelete,
onEdit,
}: {
output: DeclaredOutputConfig
output: EditableOutputConfig
editable: boolean
onAddChild?: () => void
onDelete: () => void
onEdit: () => void
}) {
@@ -43,6 +53,16 @@ function OutputRow({
</div>
{editable && (
<div className="pointer-events-none flex shrink-0 items-center gap-x-0.5 opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
{onAddChild && (
<button
type="button"
aria-label={`${t('operation.add', { ns: 'common' })} ${output.name}`}
className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={onAddChild}
>
<span aria-hidden="true" className="i-ri-add-circle-line size-4" />
</button>
)}
<button
type="button"
aria-label={t('nodes.agent.outputVars.edit', { ns: 'workflow', name: output.name })}
@@ -70,6 +90,22 @@ function OutputRow({
</div>
)
}
function ChildOutputFrame({ children, depth }: { children: ReactNode, depth: number }) {
return (
<div className="flex items-stretch">
{Array.from({ length: depth }, (_, index) => (
<div key={index} aria-hidden="true" className="flex w-5 shrink-0 justify-center">
<div className="w-px bg-divider-subtle" />
</div>
))}
<div className="min-w-0 flex-1">
{children}
</div>
</div>
)
}
export function AgentOutputVariables({
outputs,
onChange,
@@ -80,49 +116,151 @@ export function AgentOutputVariables({
setEditingState({ draft: createDraft() })
}
function handleEditOutput(index: number) {
setEditingState({ index, draft: createDraft(outputs[index]) })
setEditingState({ outputIndex: index, draft: createDraft(outputs[index]) })
}
function handleNewChild(outputIndex: number, parentPath: number[]) {
setEditingState({ outputIndex, parentPath, draft: createDraft() })
}
function handleEditChild(outputIndex: number, childPath: number[], child: DeclaredOutputChildConfig) {
setEditingState({ outputIndex, childPath, draft: createDraft(child) })
}
function handleDeleteOutput(index: number) {
onChange(outputs.filter((_, outputIndex) => outputIndex !== index))
}
function handleConfirm(output: DeclaredOutputConfig, index?: number) {
if (typeof index === 'number') {
onChange(outputs.map((item, outputIndex) => outputIndex === index ? output : item))
function handleDeleteChild(outputIndex: number, childPath: number[]) {
const parent = outputs[outputIndex]
if (!parent)
return
onChange(outputs.map((item, itemIndex) => (
itemIndex === outputIndex ? deleteOutputChildAtPath(item, childPath) : item
)))
}
function handleConfirm(output: DeclaredOutputConfig, state: EditingState) {
if (typeof state.outputIndex === 'number' && state.parentPath) {
const parent = outputs[state.outputIndex]
if (!parent)
return
const childOutput = toDeclaredOutputChild(output)
onChange(outputs.map((item, outputIndex) => (
outputIndex === state.outputIndex ? insertOutputChildAtPath(item, state.parentPath!, childOutput) : item
)))
}
else if (typeof state.outputIndex === 'number' && state.childPath) {
const childOutput = toDeclaredOutputChild(output)
onChange(outputs.map((item, outputIndex) => (
outputIndex === state.outputIndex ? updateOutputChildAtPath(item, state.childPath!, childOutput) : item
)))
}
else if (typeof state.outputIndex === 'number') {
onChange(outputs.map((item, outputIndex) => outputIndex === state.outputIndex ? output : item))
}
else {
onChange([...outputs, output])
}
setEditingState(null)
}
return (
<OutputVars>
<div className="pb-2">
<div className="flex flex-col">
{outputs.map((output, index) => (
editingState?.index === index
? (
function renderChildren(output: DeclaredOutputConfig, outputIndex: number, depth: number, children: DeclaredOutputChildConfig[], editable: boolean, parentPath: number[] = []) {
return (
<>
{children.map((child, childIndex) => {
const childPath = [...parentPath, childIndex]
const nestedChildren = getOutputChildren(child)
const isEditingChild = editingState?.outputIndex === outputIndex && pathsEqual(editingState.childPath, childPath)
return (
<div key={`${childPath.join('.')}-${child.name}-${getOutputTypeOptionValue(child)}`} className="flex flex-col">
{isEditingChild
? (
<ChildOutputFrame depth={depth}>
<OutputEditCard
allowDefaultValue={false}
editingIndex={childIndex}
existingOutputs={getOutputChildrenAtPath(output, parentPath)}
state={editingState}
onCancel={() => setEditingState(null)}
onConfirm={handleConfirm}
/>
</ChildOutputFrame>
)
: (
<ChildOutputFrame depth={depth}>
<OutputRow
output={child}
editable={editable}
onAddChild={editable && canOutputHaveChildren(child) ? () => handleNewChild(outputIndex, childPath) : undefined}
onDelete={() => handleDeleteChild(outputIndex, childPath)}
onEdit={() => handleEditChild(outputIndex, childPath, child)}
/>
</ChildOutputFrame>
)}
{renderChildren(output, outputIndex, depth + 1, nestedChildren, editable, childPath)}
{editingState?.outputIndex === outputIndex && pathsEqual(editingState.parentPath, childPath) && (
<ChildOutputFrame depth={depth + 1}>
<OutputEditCard
key={`${output.name}-editing`}
existingOutputs={outputs}
allowDefaultValue={false}
existingOutputs={nestedChildren}
state={editingState}
onCancel={() => setEditingState(null)}
onConfirm={handleConfirm}
/>
)
: (
<OutputRow
key={`${output.name}-${getOutputTypeOptionValue(output)}`}
output={output}
editable={!isDefaultOutput(output)}
onDelete={() => handleDeleteOutput(index)}
onEdit={() => handleEditOutput(index)}
/>
)
))}
</ChildOutputFrame>
)}
</div>
)
})}
</>
)
}
return (
<OutputVars>
<div className="pb-2">
<div className="flex flex-col">
{outputs.map((output, index) => {
const editable = !isDefaultOutput(output)
const children = getOutputChildren(output)
const isEditingOutput = editingState?.outputIndex === index && !editingState.childPath && !editingState.parentPath
return (
<div key={`${output.name}-${getOutputTypeOptionValue(output)}`} className="flex flex-col">
{isEditingOutput
? (
<OutputEditCard
key={`${output.name}-editing`}
editingIndex={index}
existingOutputs={outputs}
state={editingState}
onCancel={() => setEditingState(null)}
onConfirm={handleConfirm}
/>
)
: (
<OutputRow
output={output}
editable={editable}
onAddChild={editable && canOutputHaveChildren(output) ? () => handleNewChild(index, []) : undefined}
onDelete={() => handleDeleteOutput(index)}
onEdit={() => handleEditOutput(index)}
/>
)}
{renderChildren(output, index, 1, children, editable)}
{editingState?.outputIndex === index && editingState.parentPath && !editingState.parentPath.length && (
<ChildOutputFrame depth={1}>
<OutputEditCard
allowDefaultValue={false}
existingOutputs={children}
state={editingState}
onCancel={() => setEditingState(null)}
onConfirm={handleConfirm}
/>
</ChildOutputFrame>
)}
</div>
)
})}
<div className="py-1">
<Divider type="horizontal" className="h-px bg-divider-subtle" />
</div>
{editingState && editingState.index == null
{editingState && editingState.outputIndex == null
? (
<OutputEditCard
existingOutputs={outputs}
@@ -149,3 +287,10 @@ export function AgentOutputVariables({
</OutputVars>
)
}
function pathsEqual(left?: number[], right?: number[]) {
if (!left || !right || left.length !== right.length)
return false
return left.every((item, index) => item === right[index])
}
@@ -2,6 +2,10 @@ import type { DeclaredOutputConfig, DeclaredOutputType } from '@dify/contracts/a
import type { TFunction } from 'i18next'
import { defaultAgentV2DeclaredOutputs } from '../../output-variables'
export type DeclaredOutputChildConfig = NonNullable<DeclaredOutputConfig['children']>[number]
export type EditableOutputConfig = DeclaredOutputConfig | DeclaredOutputChildConfig
export type OutputTypeOptionValue
= DeclaredOutputType
| 'array[boolean]'
@@ -23,11 +27,14 @@ export type OutputDraft = {
name: string
required: boolean
type: OutputTypeOptionValue
children: DeclaredOutputChildConfig[]
}
export type EditingState = {
draft: OutputDraft
index?: number
outputIndex?: number
childPath?: number[]
parentPath?: number[]
}
export type AgentOutputVariablesProps = {
@@ -51,7 +58,7 @@ export const OUTPUT_TYPE_OPTIONS: OutputTypeOption[] = [
{ value: 'array[file]', label: 'array[file]', type: 'array', arrayItemType: 'file' },
]
export function getOutputTypeOptionValue(output: DeclaredOutputConfig): OutputTypeOptionValue {
export function getOutputTypeOptionValue(output: EditableOutputConfig): OutputTypeOptionValue {
if (output.type !== 'array')
return output.type
@@ -62,7 +69,7 @@ export function getOutputTypeOption(value: OutputTypeOptionValue) {
return OUTPUT_TYPE_OPTIONS.find(option => option.value === value) || OUTPUT_TYPE_OPTIONS[0]!
}
export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
export function createDraft(output?: EditableOutputConfig): OutputDraft {
if (!output) {
return {
defaultValue: '',
@@ -70,6 +77,7 @@ export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
name: '',
required: false,
type: 'string',
children: [],
}
}
@@ -79,10 +87,14 @@ export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
name: output.name,
required: output.required ?? true,
type: getOutputTypeOptionValue(output),
children: getOutputChildren(output),
}
}
export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig {
export function createOutputFromDraft(
draft: OutputDraft,
{ includeDefaultValue = true }: { includeDefaultValue?: boolean } = {},
): DeclaredOutputConfig {
const option = getOutputTypeOption(draft.type)
const output: DeclaredOutputConfig = {
name: draft.name.trim(),
@@ -99,6 +111,16 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
}
}
if (draft.children.length && draft.type === 'object')
output.children = draft.children
if (draft.children.length && draft.type === 'array[object]') {
output.array_item = {
type: 'object',
children: draft.children,
}
}
if (option.type === 'file') {
output.file = {
extensions: [],
@@ -106,7 +128,7 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
}
}
if (draft.defaultValue.trim()) {
if (includeDefaultValue && draft.defaultValue.trim()) {
output.failure_strategy = {
on_failure: 'default_value',
default_value: coerceDefaultValue(draft.defaultValue, option),
@@ -116,6 +138,113 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
return output
}
export function toDeclaredOutputChild(output: DeclaredOutputConfig): DeclaredOutputChildConfig {
return {
name: output.name,
type: output.type,
required: output.required,
...(output.description ? { description: output.description } : {}),
...(output.file ? { file: output.file } : {}),
...(output.children ? { children: output.children } : {}),
...(output.array_item ? { array_item: output.array_item } : {}),
}
}
export function getOutputChildren(output: EditableOutputConfig): DeclaredOutputChildConfig[] {
if (getOutputTypeOptionValue(output) === 'array[object]')
return readOutputChildren(output.array_item?.children)
if (output.type === 'object')
return readOutputChildren(output.children)
return []
}
export function canOutputHaveChildren(output: EditableOutputConfig) {
const type = getOutputTypeOptionValue(output)
return type === 'object' || type === 'array[object]'
}
export function updateOutputChildren(
output: DeclaredOutputConfig,
children: DeclaredOutputChildConfig[],
): DeclaredOutputConfig {
if (getOutputTypeOptionValue(output) === 'array[object]') {
return {
...output,
array_item: {
type: 'object',
...output.array_item,
children: children.length ? children : undefined,
},
}
}
if (output.type === 'object') {
return {
...output,
children: children.length ? children : undefined,
}
}
return output
}
export function getOutputChildrenAtPath(
output: DeclaredOutputConfig,
path: number[],
): DeclaredOutputChildConfig[] {
const target = getOutputChildAtPath(output, path)
return target ? getOutputChildren(target) : getOutputChildren(output)
}
export function getOutputChildAtPath(
output: DeclaredOutputConfig,
path: number[],
): DeclaredOutputChildConfig | undefined {
let current: DeclaredOutputChildConfig | undefined
let children = getOutputChildren(output)
for (const index of path) {
current = children[index]
if (!current)
return undefined
children = getOutputChildren(current)
}
return current
}
export function insertOutputChildAtPath(
output: DeclaredOutputConfig,
parentPath: number[],
child: DeclaredOutputChildConfig,
) {
return updateOutputChildrenAtPath(output, parentPath, children => [...children, child])
}
export function updateOutputChildAtPath(
output: DeclaredOutputConfig,
childPath: number[],
child: DeclaredOutputChildConfig,
) {
const childIndex = childPath.at(-1)
if (childIndex == null)
return output
return updateOutputChildrenAtPath(output, childPath.slice(0, -1), children => children.map((item, index) => index === childIndex ? child : item))
}
export function deleteOutputChildAtPath(
output: DeclaredOutputConfig,
childPath: number[],
) {
const childIndex = childPath.at(-1)
if (childIndex == null)
return output
return updateOutputChildrenAtPath(output, childPath.slice(0, -1), children => children.filter((_, index) => index !== childIndex))
}
export function getDefaultValueErrorKey(draft: OutputDraft) {
const trimmed = draft.defaultValue.trim()
if (!trimmed)
@@ -157,7 +286,7 @@ export function isDefaultOutput(output: DeclaredOutputConfig) {
)
}
export function getOutputDescription(output: DeclaredOutputConfig, t: TFunction) {
export function getOutputDescription(output: EditableOutputConfig, t: TFunction) {
if (output.name === 'text')
return t('nodes.agent.outputVars.text', { ns: 'workflow' })
if (output.name === 'files')
@@ -167,11 +296,83 @@ export function getOutputDescription(output: DeclaredOutputConfig, t: TFunction)
return output.description || ''
}
export function getOutputDisplayType(output: DeclaredOutputConfig) {
export function getOutputDisplayType(output: EditableOutputConfig) {
return getOutputTypeOption(getOutputTypeOptionValue(output)).label
}
function getOutputDefaultValue(output: DeclaredOutputConfig) {
function readOutputChildren(children: EditableOutputConfig['children']) {
return (children ?? []) as DeclaredOutputChildConfig[]
}
function updateOutputChildrenAtPath(
output: DeclaredOutputConfig,
parentPath: number[],
updater: (children: DeclaredOutputChildConfig[]) => DeclaredOutputChildConfig[],
): DeclaredOutputConfig {
if (!parentPath.length)
return updateOutputChildren(output, updater(getOutputChildren(output)))
const [childIndex, ...restPath] = parentPath
if (childIndex == null)
return output
const children = getOutputChildren(output)
const nextChildren = children.map((child, index) => (
index === childIndex ? updateChildChildrenAtPath(child, restPath, updater) : child
))
return updateOutputChildren(output, nextChildren)
}
function updateChildChildrenAtPath(
child: DeclaredOutputChildConfig,
parentPath: number[],
updater: (children: DeclaredOutputChildConfig[]) => DeclaredOutputChildConfig[],
): DeclaredOutputChildConfig {
if (!parentPath.length)
return updateChildChildren(child, updater(getOutputChildren(child)))
const [childIndex, ...restPath] = parentPath
if (childIndex == null)
return child
const children = getOutputChildren(child)
const nextChildren = children.map((nestedChild, index) => (
index === childIndex ? updateChildChildrenAtPath(nestedChild, restPath, updater) : nestedChild
))
return updateChildChildren(child, nextChildren)
}
function updateChildChildren(
child: DeclaredOutputChildConfig,
children: DeclaredOutputChildConfig[],
): DeclaredOutputChildConfig {
if (getOutputTypeOptionValue(child) === 'array[object]') {
return {
...child,
array_item: {
type: 'object',
...child.array_item,
children: children.length ? children : undefined,
},
}
}
if (child.type === 'object') {
return {
...child,
children: children.length ? children : undefined,
}
}
return child
}
function getOutputDefaultValue(output: EditableOutputConfig) {
if (!('failure_strategy' in output))
return ''
const defaultValue = output.failure_strategy?.default_value
if (defaultValue == null)
return ''
@@ -12,6 +12,12 @@ import {
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import {
Popover,
@@ -76,7 +82,7 @@ function InlineSetupAvatar({
}) {
return (
<span className={cn('flex size-8 shrink-0 items-center justify-center rounded-full bg-background-default-burn', className)}>
<span aria-hidden className="i-custom-vender-agent-v2-robot-3 size-5 text-text-tertiary" />
<span aria-hidden className="i-custom-vender-agent-v2-configure h-3.5 w-3 text-text-tertiary" />
</span>
)
}
@@ -91,23 +97,30 @@ function AgentRosterDrawer({
showAccessIcon = true,
showConsoleLink = true,
showDetailActions = true,
isCopyPending = false,
onMakeCopy,
onSaveInlineToRoster,
onClose,
}: {
agent: AgentRosterDisplayData
children?: ReactNode
isInlineSetup?: boolean
isCopyPending?: boolean
mode?: AgentRosterDrawerMode
open: boolean
portalContainerRef: RefObject<HTMLDivElement | null>
showAccessIcon?: boolean
showConsoleLink?: boolean
showDetailActions?: boolean
onMakeCopy?: () => void
onSaveInlineToRoster?: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const isSetup = mode === 'setup'
const title = isInlineSetup ? t(`${i18nPrefix}.roster.inlineSetup.name`, { ns: 'workflow' }) : agent.name
const description = isSetup ? t(`${i18nPrefix}.roster.inlineSetup.description`, { ns: 'workflow' }) : agent.role
const showInlineActions = isInlineSetup && !!onSaveInlineToRoster
return (
<Drawer
@@ -160,16 +173,27 @@ function AgentRosterDrawer({
</div>
</div>
<div className="flex shrink-0 items-center gap-1 py-1">
<button
type="button"
aria-label={t(`${i18nPrefix}.roster.more`, { ns: 'workflow' })}
className="flex size-6 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-ri-more-fill size-4" />
</button>
<div className="flex h-3.5 items-start px-1">
<div className="h-full w-px bg-divider-regular" />
</div>
{showInlineActions && (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t(`${i18nPrefix}.roster.more`, { ns: 'workflow' })}
className="flex size-6 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44 w-max">
<DropdownMenuItem className="gap-2 whitespace-nowrap" onClick={onSaveInlineToRoster}>
<span aria-hidden className="i-ri-inbox-archive-line size-4 shrink-0 text-text-tertiary" />
<span>{t('roster.saveToRoster', { ns: 'agentV2' })}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex h-3.5 items-start px-1">
<div className="h-full w-px bg-divider-regular" />
</div>
</>
)}
<DrawerCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="size-6 rounded-md"
@@ -193,6 +217,8 @@ function AgentRosterDrawer({
variant="secondary"
size="medium"
className="min-w-0 flex-1 gap-1.5 px-3"
loading={isCopyPending}
onClick={onMakeCopy}
>
<span aria-hidden className="i-ri-file-copy-2-line size-4 shrink-0" />
<span className="truncate">
@@ -222,6 +248,7 @@ export function AgentRosterField({
agentId,
canOpenPanel = true,
isPanelOpen,
isPanelCopyPending = false,
isPending = false,
isLoading = false,
isInlineSetup = false,
@@ -230,13 +257,16 @@ export function AgentRosterField({
showPanelDetailActions = true,
portalContainerRef,
onChange,
onMakeCopy,
onPanelOpenChange,
onSaveInlineToRoster,
onStartFromScratch,
}: {
agent?: AgentRosterDisplayData
agentId?: string
canOpenPanel?: boolean
isPanelOpen?: boolean
isPanelCopyPending?: boolean
isLoading?: boolean
isInlineSetup?: boolean
isPending?: boolean
@@ -245,7 +275,9 @@ export function AgentRosterField({
showPanelDetailActions?: boolean
portalContainerRef: RefObject<HTMLDivElement | null>
onChange: (agent: AgentRosterNodeData) => void
onMakeCopy?: () => void
onPanelOpenChange?: (open: boolean) => void
onSaveInlineToRoster?: () => void
onStartFromScratch?: () => void
}) {
const { t } = useTranslation()
@@ -358,6 +390,9 @@ export function AgentRosterField({
portalContainerRef={portalContainerRef}
showAccessIcon={!isInlineSetup}
showDetailActions={showPanelDetailActions}
isCopyPending={isPanelCopyPending}
onMakeCopy={onMakeCopy}
onSaveInlineToRoster={onSaveInlineToRoster}
onClose={() => setPanelOpen(false)}
>
{panelBody}
@@ -0,0 +1,166 @@
'use client'
import type { AgentComposerAgentResponse, AgentComposerBindingResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { AgentFormValues, AgentIconSelection } from '@/features/agent-v2/roster/components/agent-form'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Form } from '@langgenius/dify-ui/form'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import { createAgentIconSelection, defaultAgentIcon } from '@/features/agent-v2/roster/components/agent-form'
import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields'
import { consoleQuery } from '@/service/client'
type SaveInlineAgentToRosterDialogProps = {
appId?: string
formKey: number
initialAgent?: AgentComposerAgentResponse | null
nodeId: string
open: boolean
onOpenChange: (open: boolean) => void
onSaved: (binding: AgentComposerBindingResponse) => void
}
export function SaveInlineAgentToRosterDialog({
appId,
formKey,
initialAgent,
nodeId,
open,
onOpenChange,
onSaved,
}: SaveInlineAgentToRosterDialogProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const [name, setName] = useState(initialAgent?.name ?? '')
const [description, setDescription] = useState(initialAgent?.description ?? '')
const [role, setRole] = useState(initialAgent?.role ?? '')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [isIconChanged, setIsIconChanged] = useState(false)
const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => initialAgent
? createAgentIconSelection(initialAgent)
: defaultAgentIcon)
const saveToRosterMutation = useMutation(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(),
)
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setName(initialAgent?.name ?? '')
setDescription(initialAgent?.description ?? '')
setRole(initialAgent?.role ?? '')
setIsIconChanged(false)
setAgentIcon(initialAgent
? createAgentIconSelection(initialAgent)
: defaultAgentIcon)
}
else {
setIconPickerOpen(false)
}
onOpenChange(nextOpen)
}
const handleSubmit = (formValues: AgentFormValues) => {
if (saveToRosterMutation.isPending)
return
if (!appId)
return
const trimmedName = formValues.name?.trim() ?? ''
const trimmedRole = formValues.role?.trim() ?? ''
saveToRosterMutation.mutate({
params: {
app_id: appId,
node_id: nodeId,
},
body: {
variant: 'workflow',
save_strategy: 'save_to_roster',
new_agent_name: trimmedName,
description: formValues.description?.trim() ?? '',
role: trimmedRole,
...(isIconChanged
? {
icon_type: agentIcon.type,
icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon,
icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined,
}
: {}),
},
}, {
onSuccess: (composerState) => {
const binding = composerState.binding
if (binding?.binding_type !== 'roster_agent' || !binding.agent_id)
return
toast.success(t('roster.saveToRosterSuccess'))
onSaved(binding)
handleOpenChange(false)
},
})
}
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!">
<DialogCloseButton />
<div className="shrink-0 pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('roster.saveToRosterDialog.title')}
</DialogTitle>
<DialogDescription className="sr-only">
{t('roster.saveToRosterDialog.description')}
</DialogDescription>
</div>
<Form<AgentFormValues>
key={formKey}
className="min-h-0 flex-1"
onFormSubmit={handleSubmit}
>
<AgentFormFields
description={description}
icon={agentIcon}
iconAriaLabel={t('roster.saveToRosterForm.changeIcon')}
name={name}
role={role}
onDescriptionChange={setDescription}
onIconClick={() => setIconPickerOpen(true)}
onNameChange={setName}
onRoleChange={setRole}
/>
<div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6">
<Button type="button" className="min-w-18" onClick={() => handleOpenChange(false)} disabled={saveToRosterMutation.isPending}>
{tCommon('operation.cancel')}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-18"
loading={saveToRosterMutation.isPending}
>
{tCommon('operation.save')}
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
<AppIconPicker
open={iconPickerOpen}
initialEmoji={agentIcon.type === 'emoji'
? { icon: agentIcon.icon, background: agentIcon.background }
: undefined}
onOpenChange={setIconPickerOpen}
onSelect={(icon) => {
setAgentIcon(icon)
setIsIconChanged(true)
}}
/>
</>
)
}
@@ -1,6 +1,6 @@
import type { AgentInlineBinding } from '../../block-selector/types'
import { toast } from '@langgenius/dify-ui/toast'
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@@ -51,22 +51,23 @@ export function useCreateInlineAgentBinding() {
const { t } = useTranslation('agentV2')
const configsMap = useHooksStore(state => state.configsMap)
const { data: defaultModel } = useDefaultModel(ModelTypeEnum.textGeneration)
const queryClient = useQueryClient()
const {
isPending,
mutate,
mutateAsync,
} = useMutation(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(),
)
const createInlineAgentBinding = useCallback((nodeId: string, options?: CreateInlineAgentBindingOptions) => {
const createInlineAgentBinding = useCallback(async (nodeId: string, options?: CreateInlineAgentBindingOptions) => {
if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) {
toast.error(t('roster.nodeSelector.createInlineFailed'))
options?.onError?.()
return
}
mutate(
{
try {
const composerState = await mutateAsync({
params: {
app_id: configsMap.flowId,
node_id: nodeId,
@@ -82,33 +83,40 @@ export function useCreateInlineAgentBinding() {
},
agent_soul: getDefaultAgentSoul(defaultModel),
},
},
{
onSuccess: (composerState) => {
const binding = composerState.binding
})
const binding = composerState.binding
if (
binding?.binding_type !== 'inline_agent'
|| !binding.agent_id
|| !binding.current_snapshot_id
) {
toast.error(t('roster.nodeSelector.createInlineFailed'))
options?.onError?.()
return
}
if (
binding?.binding_type !== 'inline_agent'
|| !binding.agent_id
|| !binding.current_snapshot_id
) {
toast.error(t('roster.nodeSelector.createInlineFailed'))
options?.onError?.()
return
}
options?.onSuccess?.({
binding_type: 'inline_agent',
agent_id: binding.agent_id,
current_snapshot_id: binding.current_snapshot_id,
})
},
onError: () => {
options?.onError?.()
},
},
)
}, [configsMap?.flowId, configsMap?.flowType, defaultModel, mutate, t])
queryClient.setQueryData(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: {
app_id: configsMap.flowId,
node_id: nodeId,
},
},
}),
composerState,
)
options?.onSuccess?.({
binding_type: 'inline_agent',
agent_id: binding.agent_id,
current_snapshot_id: binding.current_snapshot_id,
})
}
catch {
options?.onError?.()
}
}, [configsMap?.flowId, configsMap?.flowType, defaultModel, mutateAsync, queryClient, t])
return {
createInlineAgentBinding,
@@ -24,7 +24,7 @@ function AgentNodeAvatar({
if (isInlineAgent) {
return (
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-background-default-burn text-text-tertiary">
<span aria-hidden className="i-custom-vender-agent-v2-robot-3 size-5" />
<span aria-hidden className="i-custom-vender-agent-v2-configure h-3.5 w-3" />
</span>
)
}
@@ -1,6 +1,8 @@
import type { AgentComposerBindingResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { AgentRosterNodeData } from '../../block-selector/types'
import type { NodePanelProps } from '../../types'
import type { AgentV2NodeType } from './types'
import { useMutation } from '@tanstack/react-query'
import { produce } from 'immer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -10,12 +12,14 @@ import {
} from '@/app/components/base/prompt-editor/plugins/agent-output-block/utils'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks'
import { useStore } from '@/app/components/workflow/store'
import { consoleQuery } from '@/service/client'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { AgentAdvancedSettings } from './components/agent-advanced-settings'
import { AgentOrchestrateDrawerPanel } from './components/agent-orchestrate-drawer-panel'
import { AgentOutputVariables } from './components/agent-output-variables'
import { AgentRosterField } from './components/agent-roster-field'
import { AgentTaskField } from './components/agent-task-field'
import { SaveInlineAgentToRosterDialog } from './components/save-inline-agent-to-roster-dialog'
import { useAgentRosterDetail, useCreateInlineAgentBinding, useWorkflowInlineAgentDetail } from './hooks'
import { getAgentV2DeclaredOutputs } from './output-variables'
import { hasValidInlineAgentBinding } from './types'
@@ -30,6 +34,8 @@ export function AgentV2Panel({
const promptOutputNamesRef = useRef(extractAgentOutputNames(inputs.agent_task || ''))
const [isRosterAgentPanelOpen, setIsRosterAgentPanelOpen] = useState(false)
const [isInlineAgentPanelOpenedFromTrigger, setIsInlineAgentPanelOpenedFromTrigger] = useState(false)
const [isSaveToRosterDialogOpen, setIsSaveToRosterDialogOpen] = useState(false)
const [saveToRosterSessionKey, setSaveToRosterSessionKey] = useState(0)
const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const openInlineAgentPanelNodeId = useStore(state => state.openInlineAgentPanelNodeId)
const setOpenInlineAgentPanelNodeId = useStore(state => state.setOpenInlineAgentPanelNodeId)
@@ -40,18 +46,25 @@ export function AgentV2Panel({
const inlineAgentId = inputs.agent_binding?.binding_type === 'inline_agent' ? inputs.agent_binding.agent_id : undefined
const isInlineAgentReady = hasValidInlineAgentBinding(inputs)
const isInlineAgentPending = inputs.agent_binding?.binding_type === 'inline_agent' && !isInlineAgentReady
const isInlineAgentPanelOpen = isInlineAgentReady && openInlineAgentPanelNodeId === id
const isInlineAgentPanelOpen = (isInlineAgentReady || isInlineAgentPending) && openInlineAgentPanelNodeId === id
const rosterAgentQuery = useAgentRosterDetail(rosterAgentId)
const inlineAgentQuery = useWorkflowInlineAgentDetail(id, inlineAgentId)
const { createInlineAgentBinding, isCreatingInlineAgent } = useCreateInlineAgentBinding()
const inlineAgent = inlineAgentQuery.data?.agent
const isAgentPanelOpen = isInlineAgentReady ? isInlineAgentPanelOpen : isRosterAgentPanelOpen
const isInlineAgentLoading = isInlineAgentReady && !inlineAgent
const {
isPending: isCopyingFromRoster,
mutate: copyFromRoster,
} = useMutation(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions(),
)
const isAgentPanelOpen = isInlineAgentReady || isInlineAgentPending ? isInlineAgentPanelOpen : isRosterAgentPanelOpen
const isInlineAgentLoading = isInlineAgentPending || (isInlineAgentReady && !inlineAgent)
const isAgentBindingPending = isInlineAgentPending || isCreatingInlineAgent
const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent'
const displayedAgent = rosterAgentQuery.data ?? (inlineAgentId && isInlineAgentReady
const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent
const displayedAgent = rosterAgentQuery.data ?? (isInlineAgentPending || isInlineAgentReady
? {
id: inlineAgentId,
id: inlineAgentId ?? id,
name: inlineAgent?.name || t('nodes.agent.roster.inlineSetup.name', { ns: 'workflow' }),
description: inlineAgent?.description,
role: t('nodes.agent.roster.inlineSetup.type', { ns: 'workflow' }),
@@ -113,13 +126,117 @@ export function AgentV2Panel({
)
}, [handleNodeDataUpdateWithSyncDraft, id, inputs, setOpenInlineAgentPanelNodeId])
const handleStartFromScratch = useCallback(() => {
createInlineAgentBinding(id, {
onSuccess: (binding) => {
const handleMakeRosterCopy = useCallback(() => {
if (!appId || !rosterAgentId || isCopyingFromRoster)
return
copyFromRoster({
params: {
app_id: appId,
node_id: id,
},
body: {
source_agent_id: rosterAgentId,
},
}, {
onSuccess: (composerState) => {
const binding = composerState.binding
if (
binding?.binding_type !== 'inline_agent'
|| !binding.agent_id
|| !binding.current_snapshot_id
) {
return
}
setIsRosterAgentPanelOpen(false)
setIsInlineAgentPanelOpenedFromTrigger(false)
setIsInlineAgentPanelOpenedFromTrigger(true)
setOpenInlineAgentPanelNodeId(id)
const newInputs = produce(inputsRef.current, (draft) => {
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
draft.agent_binding = {
binding_type: 'inline_agent',
agent_id: binding.agent_id,
current_snapshot_id: binding.current_snapshot_id,
}
draft._openInlineAgentPanel = true
})
inputsRef.current = newInputs
handleNodeDataUpdateWithSyncDraft(
{
id,
data: newInputs,
},
{
sync: true,
notRefreshWhenSyncError: true,
},
)
},
})
}, [appId, copyFromRoster, handleNodeDataUpdateWithSyncDraft, id, isCopyingFromRoster, rosterAgentId, setOpenInlineAgentPanelNodeId])
const handleSaveInlineToRosterOpen = useCallback(() => {
setSaveToRosterSessionKey(key => key + 1)
setIsSaveToRosterDialogOpen(true)
}, [])
const handleInlineSavedToRoster = useCallback((binding: AgentComposerBindingResponse) => {
if (binding.binding_type !== 'roster_agent' || !binding.agent_id)
return
setOpenInlineAgentPanelNodeId(undefined)
setIsInlineAgentPanelOpenedFromTrigger(false)
setIsRosterAgentPanelOpen(true)
const newInputs = produce(inputsRef.current, (draft) => {
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
delete draft._openInlineAgentPanel
draft.agent_binding = {
binding_type: 'roster_agent',
agent_id: binding.agent_id!,
}
})
inputsRef.current = newInputs
handleNodeDataUpdateWithSyncDraft(
{
id,
data: newInputs,
},
{
sync: true,
notRefreshWhenSyncError: true,
},
)
}, [handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId])
const handleStartFromScratch = useCallback(() => {
setIsRosterAgentPanelOpen(false)
setIsInlineAgentPanelOpenedFromTrigger(false)
setOpenInlineAgentPanelNodeId(id)
const pendingInputs = produce(inputsRef.current, (draft) => {
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
draft.agent_binding = {
binding_type: 'inline_agent',
}
draft._openInlineAgentPanel = true
})
inputsRef.current = pendingInputs
handleNodeDataUpdateWithSyncDraft(
{
id,
data: pendingInputs,
},
{
sync: true,
notRefreshWhenSyncError: true,
},
)
createInlineAgentBinding(id, {
onSuccess: (binding) => {
const newInputs = produce(inputsRef.current, (draft) => {
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
draft.agent_binding = binding
@@ -196,31 +313,44 @@ export function AgentV2Panel({
<div className="border-b border-divider-subtle">
<AgentRosterField
agent={displayedAgent}
agentId={rosterAgentId ?? inlineAgentId ?? undefined}
canOpenPanel={!isInlineAgentPending}
isInlineSetup={isInlineAgentReady}
agentId={rosterAgentId ?? inlineAgentId ?? (isInlineAgentPending ? id : undefined)}
canOpenPanel
isInlineSetup={isInlineAgentReady || isInlineAgentPending}
isLoading={isInlineAgentLoading}
isPanelCopyPending={isCopyingFromRoster}
isPanelOpen={isAgentPanelOpen}
isPending={isAgentBindingPending}
panelBody={isAgentPanelOpen && displayedAgent
? (
<AgentOrchestrateDrawerPanel
agentId={displayedAgent.id}
agentId={inlineAgentId ?? rosterAgentId}
appId={appId}
inlineComposerState={inlineAgentQuery.data}
isInline={isInlineAgentReady}
isInline={isInlineAgentReady || isInlineAgentPending}
nodeId={id}
open={isAgentPanelOpen}
/>
)
: undefined}
panelMode={isInlineAgentReady && !isInlineAgentPanelOpenedFromTrigger ? 'setup' : 'detail'}
panelMode={isInlineAgentPending || (isInlineAgentReady && !isInlineAgentPanelOpenedFromTrigger) ? 'setup' : 'detail'}
portalContainerRef={drawerPortalContainerRef}
showPanelDetailActions={!isInlineAgentReady}
showPanelDetailActions={!isInlineAgentReady && !isInlineAgentPending}
onChange={handleRosterChange}
onMakeCopy={rosterAgentId ? handleMakeRosterCopy : undefined}
onPanelOpenChange={handleAgentPanelOpenChange}
onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined}
onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined}
/>
<SaveInlineAgentToRosterDialog
key={saveToRosterSessionKey}
appId={appId}
formKey={saveToRosterSessionKey}
initialAgent={inlineAgent}
nodeId={id}
open={isSaveToRosterDialogOpen}
onOpenChange={setIsSaveToRosterDialogOpen}
onSaved={handleInlineSavedToRoster}
/>
</div>
<div
aria-disabled={isInlineAgentPending}

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