From 83dd03f43035693358bc3473580ac47fa0beba1e Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Wed, 24 Jun 2026 16:15:24 +0800 Subject: [PATCH] feat(agent): version drive refs in agent soul --- api/controllers/console/app/agent.py | 44 +- .../console/app/agent_drive_inspector.py | 60 ++- .../inner_api/plugin/agent_drive.py | 35 +- .../nodes/agent_v2/runtime_request_builder.py | 55 ++- api/fields/agent_fields.py | 4 + api/models/agent_config_entities.py | 13 + api/services/agent/composer_candidates.py | 4 + api/services/agent/composer_service.py | 113 ++++- .../agent/skill_standardize_service.py | 4 +- api/services/agent/soul_files_service.py | 392 ++++++++++++++++++ .../agent/workflow_publish_service.py | 20 +- .../agent_v2/test_runtime_request_builder.py | 14 + .../services/agent/test_agent_services.py | 39 +- .../agent/test_agent_soul_files_service.py | 120 ++++++ .../agent/test_skill_standardize_service.py | 2 + 15 files changed, 873 insertions(+), 46 deletions(-) create mode 100644 api/services/agent/soul_files_service.py create mode 100644 api/tests/unit_tests/services/agent/test_agent_soul_files_service.py diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 86a3c473547..d16b5cc77cb 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -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 { @@ -276,6 +306,12 @@ 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 + _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} @@ -301,6 +337,12 @@ def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, a ) 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=result, + ) removed_keys = [item["key"] for item in result if item.get("removed")] return {"result": "success", "removed_keys": removed_keys} diff --git a/api/controllers/console/app/agent_drive_inspector.py b/api/controllers/console/app/agent_drive_inspector.py index bd639955d9c..738b27750d5 100644 --- a/api/controllers/console/app/agent_drive_inspector.py +++ b/api/controllers/console/app/agent_drive_inspector.py @@ -28,10 +28,12 @@ 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.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 +162,50 @@ 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 AgentDriveService().manifest(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 _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 +230,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 +249,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 +268,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,6 +294,7 @@ class AgentDrivePreviewByAgentApi(Resource): query = query_params_from_request(AgentDriveFileByAgentQuery) resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) try: + _assert_key_in_active_soul(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) except AgentDriveError as exc: return _handle(exc) @@ -266,6 +314,7 @@ class AgentDriveDownloadByAgentApi(Resource): query = query_params_from_request(AgentDriveFileByAgentQuery) resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) try: + _assert_key_in_active_soul(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) except AgentDriveError as exc: return _handle(exc) @@ -288,7 +337,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 +361,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 +389,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,6 +417,7 @@ class AgentDrivePreviewApi(Resource): if not agent_id: return _agent_not_bound() try: + _assert_key_in_active_soul(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key) return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key) except AgentDriveError as exc: return _handle(exc) @@ -388,6 +439,7 @@ class AgentDriveDownloadApi(Resource): if not agent_id: return _agent_not_bound() try: + _assert_key_in_active_soul(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key) url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key) except AgentDriveError as exc: return _handle(exc) diff --git a/api/controllers/inner_api/plugin/agent_drive.py b/api/controllers/inner_api/plugin/agent_drive.py index 0cdb9dab35f..1177b6d8a1e 100644 --- a/api/controllers/inner_api/plugin/agent_drive.py +++ b/api/controllers/inner_api/plugin/agent_drive.py @@ -17,6 +17,8 @@ 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 services.agent.soul_files_service import AgentSoulFilesService from services.agent_drive_service import ( AgentDriveError, AgentDriveService, @@ -35,6 +37,28 @@ 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 = AgentDriveService().manifest( + tenant_id=tenant_id, + agent_id=agent_id, + prefix=normalized_prefix, + include_download_url=include_download_url, + ) + skill_prefixes = AgentSoulFilesService.allowed_skill_prefixes(agent_soul) + if normalized_prefix and any(normalized_prefix.startswith(p) for p in skill_prefixes): + return items + allowed_keys = AgentSoulFilesService.allowed_drive_keys(agent_soul) + return [item for item in items if item.get("key") in allowed_keys] + + @inner_api_ns.route("/drive//manifest") class AgentDriveManifestApi(Resource): @setup_required @@ -48,7 +72,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 +95,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 +121,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} diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 9eab82a8afc..ae11b9996dd 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -71,7 +71,8 @@ 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 AgentDriveError, AgentDriveService, decode_drive_mention_ref from .output_failure_orchestrator import retry_idempotency_key from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError @@ -669,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"): @@ -688,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 @@ -702,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) @@ -721,9 +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) + 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 = {file_ref.drive_key for file_ref in agent_soul.files.files if file_ref.drive_key} + try: + manifest_items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=agent_id) + except AgentDriveError: + manifest_items = [] manifest_by_key = {item["key"]: item for item in manifest_items} skill_keys = {skill["skill_md_key"] for skill in skills_catalog} warnings: list[dict[str, str]] = [] @@ -733,7 +751,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( @@ -743,6 +761,15 @@ def build_drive_layer_config( "message": f"drive mention '{drive_key}' has no matching drive entry.", } ) + for drive_key in sorted(skill_keys | soul_file_keys): + if drive_key not in manifest_by_key: + warnings.append( + { + "section": "agent_soul.files", + "code": "drive_value_missing", + "message": f"Agent Soul drive ref '{drive_key}' has no backing drive value.", + } + ) skills = [ DifyDriveSkillConfig( diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index 554cd5ad0fb..204be4d412e 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -16,8 +16,10 @@ from models.agent import ( ) from models.agent_config_entities import ( AgentCliToolConfig, + AgentFileRefConfig, AgentHumanContactConfig, AgentKnowledgeDatasetConfig, + AgentSkillRefConfig, AgentSoulConfig, DeclaredOutputConfig, DeclaredOutputType, @@ -421,6 +423,8 @@ class AgentComposerSoulCandidatesResponse(ResponseModel): cli_tools: list[AgentCliToolConfig] = 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): diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 2f81495e9f9..e31d899fe37 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -162,6 +162,18 @@ class AgentSkillRefConfig(AgentFlexibleConfig): manifest_files: list[str] | None = None +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): model_config = ConfigDict(extra="ignore") @@ -679,6 +691,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) diff --git a/api/services/agent/composer_candidates.py b/api/services/agent/composer_candidates.py index a650b16e9bc..4de4766de1b 100644 --- a/api/services/agent/composer_candidates.py +++ b/api/services/agent/composer_candidates.py @@ -172,6 +172,8 @@ def soul_candidates( ) 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 = { @@ -179,6 +181,8 @@ def soul_candidates( "cli_tools": cli_tools, "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(): diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 350a7355b22..3fefb9b6ffc 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -42,6 +42,7 @@ from services.agent.knowledge_datasets import ( 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, @@ -333,6 +334,11 @@ class AgentComposerService: except IntegrityError as exc: db.session.rollback() raise AgentNameConflictError() from exc + payload.agent_soul = cls._preserve_active_soul_files( + tenant_id=tenant_id, + agent_id=agent.id, + agent_soul=payload.agent_soul, + ) if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id: version = cls._create_config_version( @@ -392,7 +398,7 @@ class AgentComposerService: 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 @@ -446,14 +452,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) @@ -474,6 +482,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( @@ -759,6 +789,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, @@ -859,6 +894,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, @@ -893,6 +933,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, @@ -925,6 +970,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, @@ -940,6 +991,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, @@ -975,6 +1035,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, @@ -1116,26 +1179,38 @@ 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 + @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 []: diff --git a/api/services/agent/skill_standardize_service.py b/api/services/agent/skill_standardize_service.py index 7e64d6f0422..c6090411f37 100644 --- a/api/services/agent/skill_standardize_service.py +++ b/api/services/agent/skill_standardize_service.py @@ -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 diff --git a/api/services/agent/soul_files_service.py b/api/services/agent/soul_files_service.py new file mode 100644 index 00000000000..9c6fbb7c2da --- /dev/null +++ b/api/services/agent/soul_files_service.py @@ -0,0 +1,392 @@ +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_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 + 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, + "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 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) + 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 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 '/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) + + @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, + ) + 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, + ) + 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 _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}) + if skill.full_archive_key == key + else skill + for skill in agent_soul.files.skills + ] + + @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, + } + + @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 diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index eb3766996bf..3297b3b08e0 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -21,6 +21,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, @@ -169,6 +170,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 = {file_ref.drive_key for file_ref in agent_soul.files.files if file_ref.drive_key} for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt): if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}: continue @@ -177,24 +180,37 @@ 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 + 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 = 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)), + AgentDriveFile.key.in_(check_keys), ) ).all() ) 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)}" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index c997d65701b..654abba5202 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -942,6 +942,19 @@ 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"), ) @@ -1123,6 +1136,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"), ) diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index c0493224bce..d5768f191c0 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -1095,6 +1095,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( @@ -1174,6 +1184,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( @@ -3523,6 +3544,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) @@ -3547,7 +3580,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")] @@ -3562,7 +3595,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(), ) == [] ) @@ -3574,7 +3607,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 == [] diff --git a/api/tests/unit_tests/services/agent/test_agent_soul_files_service.py b/api/tests/unit_tests/services/agent/test_agent_soul_files_service.py new file mode 100644 index 00000000000..c3e105d9569 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_agent_soul_files_service.py @@ -0,0 +1,120 @@ +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, + }, + ) + + 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", + "manifest_files": ["SKILL.md", "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", + } + ], + "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 diff --git a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py index fa47c9bd905..c5647e8d7f7 100644 --- a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py @@ -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