mirror of
https://github.com/langgenius/dify.git
synced 2026-06-27 07:31:09 +08:00
feat(agent): version agent soul drive file refs
This commit is contained in:
parent
83dd03f430
commit
a6a6bc322b
@ -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}
|
||||
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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/<string:drive_ref>/manifest")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user