feat(agent): version drive refs in agent soul

This commit is contained in:
Yansong Zhang 2026-06-24 16:15:24 +08:00
parent 1ce65bf1c9
commit 83dd03f430
15 changed files with 873 additions and 46 deletions

View File

@ -38,6 +38,7 @@ from services.agent.skill_tool_inference_service import (
SkillToolInferenceResult,
SkillToolInferenceService,
)
from services.agent.soul_files_service import AgentSoulFilesService
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
@ -181,6 +182,22 @@ def _agent_not_bound() -> tuple[dict[str, str], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _sync_active_soul_files(
*,
tenant_id: str,
agent_id: str,
account_id: str,
committed_items: list[dict[str, Any]],
) -> None:
AgentSoulFilesService.sync_drive_commit_to_active_soul(
tenant_id=tenant_id,
agent_id=agent_id,
account_id=account_id,
committed_items=committed_items,
)
db.session.commit()
def _upload_skill_for_app(*, current_user: Account, app_model: App):
"""Upload one skill package and commit its normalized files into the agent drive."""
@ -195,8 +212,9 @@ def _upload_skill_for_app(*, current_user: Account, app_model: App):
upload = request.files["file"]
content = upload.stream.read()
standardize_service = SkillStandardizeService()
try:
result = SkillStandardizeService().standardize(
result = standardize_service.standardize(
content=content,
filename=upload.filename or "",
tenant_id=app_model.tenant_id,
@ -205,6 +223,12 @@ def _upload_skill_for_app(*, current_user: Account, app_model: App):
)
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
_sync_active_soul_files(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
committed_items=standardize_service.last_committed_items,
)
return result, 201
@ -243,6 +267,12 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
_sync_active_soul_files(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
committed_items=committed,
)
row = committed[0]
return {
@ -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}

View File

@ -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)

View File

@ -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}

View File

@ -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(

View File

@ -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):

View File

@ -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)

View File

@ -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():

View File

@ -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 []:

View File

@ -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

View 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

View File

@ -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)}"

View File

@ -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"),
)

View File

@ -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 == []

View File

@ -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

View File

@ -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