mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 14:51:13 +08:00
feat(agent): version drive refs in agent soul
This commit is contained in:
parent
1ce65bf1c9
commit
83dd03f430
@ -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}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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/<string:drive_ref>/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}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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 []:
|
||||
|
||||
@ -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
|
||||
|
||||
392
api/services/agent/soul_files_service.py
Normal file
392
api/services/agent/soul_files_service.py
Normal file
@ -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 '<path>/SKILL.md' key",
|
||||
status_code=500,
|
||||
)
|
||||
return key[: -len(_SKILL_MD_SUFFIX)]
|
||||
|
||||
@staticmethod
|
||||
def skill_archive_key(skill_md_key: str) -> str:
|
||||
return f"{AgentSoulFilesService.skill_path_from_key(skill_md_key)}/{_SKILL_ARCHIVE_NAME}"
|
||||
|
||||
@classmethod
|
||||
def _apply_commit_item(cls, *, agent_soul: AgentSoulConfig, item: dict[str, Any]) -> None:
|
||||
key = normalize_drive_key(str(item.get("key") or ""))
|
||||
if item.get("removed"):
|
||||
cls._remove_ref(agent_soul=agent_soul, key=key)
|
||||
return
|
||||
|
||||
if item.get("is_skill"):
|
||||
cls._upsert_skill_ref(agent_soul=agent_soul, key=key, item=item)
|
||||
return
|
||||
if key.startswith(_FILES_PREFIX):
|
||||
cls._upsert_file_ref(agent_soul=agent_soul, key=key, item=item)
|
||||
|
||||
@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
|
||||
@ -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)}"
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@ -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 == []
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user