diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index d16b5cc77cb..43896aa1eb7 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -297,15 +297,7 @@ 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, @@ -326,24 +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 + 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, ) - 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 738b27750d5..1ae4addffd5 100644 --- a/api/controllers/console/app/agent_drive_inspector.py +++ b/api/controllers/console/app/agent_drive_inspector.py @@ -31,6 +31,7 @@ from controllers.console.wraps import account_initialization_required, setup_req 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 @@ -167,7 +168,12 @@ def _versioned_manifest(*, tenant_id: str, agent_id: str, prefix: str = "") -> l 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_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, @@ -190,6 +196,62 @@ def _assert_key_in_active_soul(*, tenant_id: str, agent_id: str, key: str) -> No ) +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("/") @@ -294,8 +356,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) + return _preview_versioned_file(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) except AgentDriveError as exc: return _handle(exc) @@ -314,8 +375,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) + 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} @@ -417,8 +477,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) + return _preview_versioned_file(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key) except AgentDriveError as exc: return _handle(exc) @@ -439,8 +498,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) + 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} diff --git a/api/controllers/inner_api/plugin/agent_drive.py b/api/controllers/inner_api/plugin/agent_drive.py index 1177b6d8a1e..6047af28056 100644 --- a/api/controllers/inner_api/plugin/agent_drive.py +++ b/api/controllers/inner_api/plugin/agent_drive.py @@ -18,6 +18,7 @@ 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, @@ -46,17 +47,31 @@ def _versioned_manifest( ) -> 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( + items = AgentSoulFilesService.list_manifest_items( + session=db.session, 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] + 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//manifest") 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 ae11b9996dd..ff15ebfad5f 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -72,7 +72,7 @@ from services.agent.prompt_mentions import ( parse_prompt_mentions, ) from services.agent.soul_files_service import AgentSoulFilesService -from services.agent_drive_service import AgentDriveError, AgentDriveService, decode_drive_mention_ref +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 @@ -737,12 +737,11 @@ def build_drive_layer_config( 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} + 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] = [] @@ -761,15 +760,6 @@ 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/models/agent_config_entities.py b/api/models/agent_config_entities.py index e31d899fe37..2cdc33d63e2 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -160,6 +160,10 @@ 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): diff --git a/api/services/agent/soul_files_service.py b/api/services/agent/soul_files_service.py index 9c6fbb7c2da..34c787386fc 100644 --- a/api/services/agent/soul_files_service.py +++ b/api/services/agent/soul_files_service.py @@ -106,6 +106,35 @@ class AgentSoulFilesService: 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, @@ -126,15 +155,16 @@ class AgentSoulFilesService: 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, + "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 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, @@ -153,6 +183,9 @@ class AgentSoulFilesService: 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 @@ -171,6 +204,18 @@ class AgentSoulFilesService: 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) @@ -223,6 +268,8 @@ class AgentSoulFilesService: 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: @@ -239,6 +286,22 @@ class AgentSoulFilesService: 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 @@ -260,12 +323,59 @@ class AgentSoulFilesService: 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] @@ -277,11 +387,35 @@ class AgentSoulFilesService: return if key.endswith(f"/{_SKILL_ARCHIVE_NAME}"): agent_soul.files.skills = [ - skill.model_copy(update={"full_archive_key": None}) + 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: @@ -336,6 +470,8 @@ class AgentSoulFilesService: "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 diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index 3297b3b08e0..16dcb3945ac 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -12,13 +12,14 @@ from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationE from models.agent import ( Agent, AgentConfigSnapshot, - AgentDriveFile, + AgentDriveFileKind, AgentScope, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig +from models.model import ToolFile, UploadFile from models.workflow import Workflow from services.agent.composer_validator import ComposerConfigValidator from services.agent.soul_files_service import AgentSoulFilesService @@ -171,7 +172,7 @@ class WorkflowAgentPublishService: 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} + 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 @@ -187,15 +188,11 @@ class WorkflowAgentPublishService: 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_(check_keys), - ) - ).all() - ) + 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: @@ -216,6 +213,35 @@ class WorkflowAgentPublishService: 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, diff --git a/api/services/agent_drive_service.py b/api/services/agent_drive_service.py index c20b3f570a3..6cf7a68996b 100644 --- a/api/services/agent_drive_service.py +++ b/api/services/agent_drive_service.py @@ -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", 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 654abba5202..a627481c378 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 @@ -960,41 +960,11 @@ def _soul_with_drive_skill() -> AgentSoulConfig: 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): @@ -1120,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={ 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 index c3e105d9569..569d2357e38 100644 --- 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 @@ -33,6 +33,26 @@ def test_apply_drive_commit_records_skill_and_file_refs_in_agent_soul(): "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] == [ { @@ -44,7 +64,34 @@ def test_apply_drive_commit_records_skill_and_file_refs_in_agent_soul(): "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] == [ @@ -71,6 +118,23 @@ def test_apply_drive_commit_removes_refs_without_touching_unrelated_entries(): "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": [ @@ -118,3 +182,32 @@ def test_drive_copy_and_access_scopes_come_from_agent_soul_files(): 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"