diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py
index c8d2134ed83..c4acb501cc3 100644
--- a/api/controllers/console/agent/roster.py
+++ b/api/controllers/console/agent/roster.py
@@ -241,6 +241,7 @@ class AgentAppPartial(GenericAppPartial):
debug_conversation_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
+ reference_count: int | None = None
published_reference_count: int = 0
published_references: list[AgentAppPublishedReferenceResponse] = Field(default_factory=list)
@@ -427,6 +428,10 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents_by_app_id.values()],
)
+ reference_counts_by_agent_id = roster_service.load_reference_counts_by_agent_id(
+ tenant_id=tenant_id,
+ agent_ids=[agent.id for agent in agents_by_app_id.values()],
+ )
debug_conversation_ids_by_agent_id = roster_service.load_or_create_agent_app_debug_conversation_ids_by_agent_id(
tenant_id=tenant_id,
agents=list(agents_by_app_id.values()),
@@ -449,6 +454,7 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_
item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id)
item["role"] = agent.role or ""
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
+ item["reference_count"] = reference_counts_by_agent_id.get(agent.id, 0)
published_references = published_references_by_agent_id.get(agent.id, [])
item["published_reference_count"] = len(published_references)
item["published_references"] = [
diff --git a/api/controllers/console/app/agent_config_inspector.py b/api/controllers/console/app/agent_config_inspector.py
index 170b3756b39..6fd28bcbb29 100644
--- a/api/controllers/console/app/agent_config_inspector.py
+++ b/api/controllers/console/app/agent_config_inspector.py
@@ -100,6 +100,7 @@ class AgentConfigSkillItemResponse(ResponseModel):
id: str
name: str
file_id: str | None = None
+ is_missing: bool = False
description: str = ""
size: int | None = None
mime_type: str | None = None
@@ -110,6 +111,7 @@ class AgentConfigFileItemResponse(ResponseModel):
id: str
name: str
file_id: str | None = None
+ is_missing: bool = False
size: int | None = None
mime_type: str | None = None
hash: str | None = None
diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py
index 3afa740e545..22b99412053 100644
--- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py
+++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py
@@ -887,8 +887,8 @@ def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
"""Resolve config skill/file mentions and delegate the rest to Agent Soul."""
base_resolver = build_soul_mention_resolver(agent_soul)
- skill_names = {item.name for item in agent_soul.config_skills}
- file_names = {item.name for item in agent_soul.config_files}
+ skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
+ file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
def _resolve(mention: object) -> str | None:
if not hasattr(mention, "kind") or not hasattr(mention, "ref_id"):
@@ -926,9 +926,20 @@ def build_config_layer_config(
if mention.kind in {MentionKind.SKILL, MentionKind.FILE} and mention.ref_id
)
)
- skill_names = {skill.name for skill in agent_soul.config_skills}
- file_names = {file_ref.name for file_ref in agent_soul.config_files}
- warnings: list[dict[str, str]] = []
+ available_skills = [skill for skill in agent_soul.config_skills if not skill.is_missing]
+ available_files = [file_ref for file_ref in agent_soul.config_files if not file_ref.is_missing]
+ skill_names = {skill.name for skill in available_skills}
+ file_names = {file_ref.name for file_ref in available_files}
+ warnings: list[dict[str, str]] = [
+ {
+ "section": "agent_soul.config",
+ "code": "config_asset_missing",
+ "message": f"config {kind} '{item.name}' is unavailable and was excluded from runtime.",
+ }
+ for kind, items in (("skill", agent_soul.config_skills), ("file", agent_soul.config_files))
+ for item in items
+ if item.is_missing
+ ]
mentioned_skill_names: list[str] = []
mentioned_file_names: list[str] = []
for name in ordered_mentions:
@@ -961,7 +972,7 @@ def build_config_layer_config(
size=skill.size,
mime_type=skill.mime_type,
)
- for skill in agent_soul.config_skills
+ for skill in available_skills
],
files=[
DifyConfigFileConfig(
@@ -969,7 +980,7 @@ def build_config_layer_config(
size=file_ref.size,
mime_type=file_ref.mime_type,
)
- for file_ref in agent_soul.config_files
+ for file_ref in available_files
],
env_keys=_agent_soul_config_env_keys(agent_soul),
note=agent_soul.config_note,
diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py
index db0dbf8dcc0..50105273e1c 100644
--- a/api/fields/agent_fields.py
+++ b/api/fields/agent_fields.py
@@ -99,6 +99,7 @@ class AgentRosterResponse(ResponseModel):
archived_at: int | None = None
created_at: int | None = None
updated_at: int | None = None
+ reference_count: int | None = None
published_reference_count: int = 0
published_node_reference_count: int = 0
published_references: list[AgentPublishedReferenceResponse] = Field(default_factory=list)
diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py
index a78f7d90dd5..7ce6a103046 100644
--- a/api/models/agent_config_entities.py
+++ b/api/models/agent_config_entities.py
@@ -195,7 +195,8 @@ class AgentConfigFileRefConfig(BaseModel):
name: str = Field(min_length=1, max_length=255)
file_kind: Literal["upload_file", "tool_file"]
- file_id: str = Field(min_length=1, max_length=255)
+ file_id: str = Field(default="", max_length=255)
+ is_missing: bool = False
size: int | None = None
hash: str | None = None
mime_type: str | None = None
@@ -205,6 +206,16 @@ class AgentConfigFileRefConfig(BaseModel):
def _validate_name(cls, value: str) -> str:
return validate_config_name(value)
+ @model_validator(mode="after")
+ def _validate_file_reference(self) -> Self:
+ if self.is_missing:
+ if self.file_id:
+ raise ValueError("missing config files must not retain a workspace-local file_id")
+ return self
+ if not self.file_id or not self.file_id.strip():
+ raise ValueError("config file file_id is required unless is_missing is true")
+ return self
+
class AgentConfigSkillRefConfig(BaseModel):
"""Stable Agent Soul reference to one normalized skill archive."""
@@ -214,7 +225,8 @@ class AgentConfigSkillRefConfig(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: str = ""
file_kind: Literal["tool_file"] = "tool_file"
- file_id: str = Field(min_length=1, max_length=255)
+ file_id: str = Field(default="", max_length=255)
+ is_missing: bool = False
size: int | None = None
hash: str | None = None
mime_type: str | None = "application/zip"
@@ -224,6 +236,16 @@ class AgentConfigSkillRefConfig(BaseModel):
def _validate_name(cls, value: str) -> str:
return validate_config_skill_name(value)
+ @model_validator(mode="after")
+ def _validate_file_reference(self) -> Self:
+ if self.is_missing:
+ if self.file_id:
+ raise ValueError("missing config skills must not retain a workspace-local file_id")
+ return self
+ if not self.file_id or not self.file_id.strip():
+ raise ValueError("config skill file_id is required unless is_missing is true")
+ return self
+
class AgentPermissionConfig(BaseModel):
model_config = ConfigDict(extra="ignore")
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index 19ed007db00..578a5b0be07 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -13282,6 +13282,7 @@ default (the config form sends the full desired feature state on save).
| permission_keys | [ string ] | | No |
| published_reference_count | integer | | No |
| published_references | [ [AgentAppPublishedReferenceResponse](#agentapppublishedreferenceresponse) ] | | No |
+| reference_count | integer | | No |
| role | string | | No |
| tags | [ [Tag](#tag) ] | | No |
| updated_at | integer | | No |
@@ -13574,6 +13575,7 @@ Editable Agent Soul draft workspace type.
| file_id | string | | No |
| hash | string | | No |
| id | string | | Yes |
+| is_missing | boolean | | No |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
@@ -13608,9 +13610,10 @@ Stable Agent Soul reference to one config file payload.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| file_id | string | | Yes |
+| file_id | string | | No |
| file_kind | string,
**Available values:** "tool_file", "upload_file" | *Enum:* `"tool_file"`, `"upload_file"` | Yes |
| hash | string | | No |
+| is_missing | boolean | | No |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
@@ -13705,6 +13708,7 @@ Audit operation recorded for Agent Soul version/revision changes.
| file_id | string | | No |
| hash | string | | No |
| id | string | | Yes |
+| is_missing | boolean | | No |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
@@ -13740,9 +13744,10 @@ Stable Agent Soul reference to one normalized skill archive.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
-| file_id | string | | Yes |
+| file_id | string | | No |
| file_kind | string,
**Default:** tool_file | | No |
| hash | string | | No |
+| is_missing | boolean | | No |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
@@ -14080,6 +14085,7 @@ Supported icon storage formats for Agent roster entries.
| published_node_reference_count | integer | | No |
| published_reference_count | integer | | No |
| published_references | [ [AgentPublishedReferenceResponse](#agentpublishedreferenceresponse) ] | | No |
+| reference_count | integer | | No |
| role | string | | No |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | Yes |
@@ -14521,6 +14527,7 @@ section may be empty, which is how callers express "no knowledge layer".
| published_node_reference_count | integer | | No |
| published_reference_count | integer | | No |
| published_references | [ [AgentPublishedReferenceResponse](#agentpublishedreferenceresponse) ] | | No |
+| reference_count | integer | | No |
| role | string | | No |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | Yes |
diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py
index a227978b764..ea1ae673b81 100644
--- a/api/services/agent/composer_validator.py
+++ b/api/services/agent/composer_validator.py
@@ -229,10 +229,20 @@ class ComposerConfigValidator:
@classmethod
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:
dumped = agent_soul.model_dump(mode="json")
+ cls._reject_missing_config_assets(agent_soul)
cls._validate_knowledge_runtime_config(agent_soul)
cls._reject_plaintext_secrets(dumped, path="agent_soul")
cls._validate_shell_config(dumped)
+ @staticmethod
+ def _reject_missing_config_assets(agent_soul: AgentSoulConfig) -> None:
+ missing = [f"skill:{item.name}" for item in agent_soul.config_skills if item.is_missing]
+ missing.extend(f"file:{item.name}" for item in agent_soul.config_files if item.is_missing)
+ if missing:
+ raise InvalidComposerConfigError(
+ "config_asset_missing: upload the missing Agent config assets before publishing: " + ", ".join(missing)
+ )
+
@classmethod
def _validate_knowledge_runtime_config(cls, agent_soul: AgentSoulConfig) -> None:
"""Validate knowledge settings that are required only for publish/run.
diff --git a/api/services/agent/dsl_entities.py b/api/services/agent/dsl_entities.py
index 3c00b2a7f5a..70dbf3a2967 100644
--- a/api/services/agent/dsl_entities.py
+++ b/api/services/agent/dsl_entities.py
@@ -99,8 +99,12 @@ def make_portable_agent_package(agent: Agent, agent_soul: AgentSoulConfig) -> Ag
)
for item in agent_soul.config_files
)
- soul_data["config_skills"] = []
- soul_data["config_files"] = []
+ for item in soul_data.get("config_skills", []):
+ item["file_id"] = ""
+ item["is_missing"] = True
+ for item in soul_data.get("config_files", []):
+ item["file_id"] = ""
+ item["is_missing"] = True
if soul_data.get("model"):
soul_data["model"]["credential_ref"] = None
diff --git a/api/services/agent/dsl_service.py b/api/services/agent/dsl_service.py
index 70e7d474f98..0200a22704f 100644
--- a/api/services/agent/dsl_service.py
+++ b/api/services/agent/dsl_service.py
@@ -427,7 +427,7 @@ class AgentDslService:
app.updated_by = account.id
self.session.add(app)
self.session.flush()
- app_was_created.send(app, account=account)
+ app_was_created.send(app, account=account, session=self.session)
self._configure_visible_agent_app_after_commit(
tenant_id=tenant_id,
app_id=app.id,
diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py
index d1b093a5a65..c6087843ae0 100644
--- a/api/services/agent/roster_service.py
+++ b/api/services/agent/roster_service.py
@@ -95,6 +95,7 @@ class AgentRosterService:
active_version: AgentConfigSnapshot | None = None,
published_references: list[AgentReferencingWorkflow] | None = None,
active_config_is_published: bool | None = None,
+ reference_count: int | None = None,
) -> dict[str, Any]:
published_references = published_references or []
return {
@@ -126,6 +127,7 @@ class AgentRosterService:
"archived_at": to_timestamp(agent.archived_at),
"created_at": to_timestamp(agent.created_at),
"updated_at": to_timestamp(agent.updated_at),
+ "reference_count": len(published_references) if reference_count is None else reference_count,
"published_reference_count": len(published_references),
"published_node_reference_count": sum(len(item["node_ids"]) for item in published_references),
"published_references": published_references,
@@ -188,6 +190,10 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents],
)
+ reference_counts_by_agent_id = self._load_reference_counts_by_agent_id(
+ tenant_id=tenant_id,
+ agent_ids=[agent.id for agent in agents],
+ )
active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=agents,
@@ -204,6 +210,7 @@ class AgentRosterService:
active_version,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
+ reference_counts_by_agent_id.get(agent.id, 0),
)
)
@@ -230,6 +237,10 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents],
)
+ reference_counts_by_agent_id = self._load_reference_counts_by_agent_id(
+ tenant_id=tenant_id,
+ agent_ids=[agent.id for agent in agents],
+ )
active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=agents,
@@ -240,6 +251,7 @@ class AgentRosterService:
versions_by_id.get(agent.active_config_snapshot_id) if agent.active_config_snapshot_id else None,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
+ reference_counts_by_agent_id.get(agent.id, 0),
)
for agent in agents
]
@@ -1067,6 +1079,10 @@ class AgentRosterService:
"""Return published workflow references grouped by roster Agent id."""
return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=agent_ids)
+ def load_reference_counts_by_agent_id(self, *, tenant_id: str, agent_ids: list[str]) -> dict[str, int]:
+ """Return current draft or published workflow app references by roster Agent id."""
+ return self._load_reference_counts_by_agent_id(tenant_id=tenant_id, agent_ids=agent_ids)
+
def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
active_version = self._get_version(
@@ -1076,6 +1092,10 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_ids=[agent.id],
)
+ reference_counts_by_agent_id = self._load_reference_counts_by_agent_id(
+ tenant_id=tenant_id,
+ agent_ids=[agent.id],
+ )
active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=[agent],
@@ -1085,6 +1105,7 @@ class AgentRosterService:
active_version,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
+ reference_counts_by_agent_id.get(agent.id, 0),
)
def update_roster_agent(
@@ -1419,6 +1440,60 @@ class AgentRosterService:
result[agent_id] = references
return result
+ def _load_reference_counts_by_agent_id(self, *, tenant_id: str, agent_ids: list[str]) -> dict[str, int]:
+ if not agent_ids:
+ return {}
+
+ bindings = list(
+ self._session.scalars(
+ select(WorkflowAgentNodeBinding).where(
+ WorkflowAgentNodeBinding.tenant_id == tenant_id,
+ WorkflowAgentNodeBinding.agent_id.in_(agent_ids),
+ WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT,
+ )
+ ).all()
+ )
+ if not bindings:
+ return {}
+
+ app_ids = {binding.app_id for binding in bindings}
+ apps = {
+ app.id: app
+ for app in self._session.scalars(
+ select(App).where(
+ App.tenant_id == tenant_id,
+ App.id.in_(app_ids),
+ App.status == AppStatus.NORMAL,
+ )
+ ).all()
+ }
+ workflow_ids = {binding.workflow_id for binding in bindings}
+ workflows = {
+ workflow.id: workflow
+ for workflow in self._session.scalars(
+ select(Workflow).where(
+ Workflow.tenant_id == tenant_id,
+ Workflow.id.in_(workflow_ids),
+ )
+ ).all()
+ }
+
+ referenced_app_ids_by_agent_id: dict[str, set[str]] = {}
+ for binding in bindings:
+ if not binding.agent_id:
+ continue
+ app = apps.get(binding.app_id)
+ workflow = workflows.get(binding.workflow_id)
+ if app is None or workflow is None or workflow.app_id != binding.app_id:
+ continue
+ if workflow.version != binding.workflow_version:
+ continue
+ if workflow.version != Workflow.VERSION_DRAFT and app.workflow_id != workflow.id:
+ continue
+ referenced_app_ids_by_agent_id.setdefault(binding.agent_id, set()).add(binding.app_id)
+
+ return {agent_id: len(app_ids) for agent_id, app_ids in referenced_app_ids_by_agent_id.items()}
+
def _load_versions_by_id(self, version_ids: list[str]) -> dict[str, AgentConfigSnapshot]:
if not version_ids:
return {}
diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py
index 0b4c6b59cbd..0997cfd2029 100644
--- a/api/services/agent/workflow_publish_service.py
+++ b/api/services/agent/workflow_publish_service.py
@@ -200,8 +200,8 @@ class WorkflowAgentPublishService:
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
del session
- configured_skill_names = {item.name for item in agent_soul.config_skills}
- configured_file_names = {item.name for item in agent_soul.config_files}
+ configured_skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
+ configured_file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
missing_refs: list[str] = []
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
diff --git a/api/services/agent_config_service.py b/api/services/agent_config_service.py
index a0da08e5c2e..ded7570eb0f 100644
--- a/api/services/agent_config_service.py
+++ b/api/services/agent_config_service.py
@@ -234,7 +234,8 @@ class AgentConfigService:
user_id=user_id,
)
skill = self._require_skill(target.agent_soul, name=name)
- payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=skill.file_id)
+ file_id = self._available_skill_file_id(skill)
+ payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
return ConfigDownload(filename=f"{skill.name}.zip", mime_type=mime_type or "application/zip", payload=payload)
def download_skill_url(
@@ -255,7 +256,8 @@ class AgentConfigService:
user_id=user_id,
)
skill = self._require_skill(target.agent_soul, name=name)
- url = self._resolve_download_url(tenant_id=tenant_id, file_kind=skill.file_kind, file_id=skill.file_id)
+ file_id = self._available_skill_file_id(skill)
+ url = self._resolve_download_url(tenant_id=tenant_id, file_kind=skill.file_kind, file_id=file_id)
if url is None:
raise AgentConfigServiceError("config_skill_not_found", "config skill payload is missing", status_code=404)
return url
@@ -278,7 +280,8 @@ class AgentConfigService:
user_id=user_id,
)
skill = self._require_skill(target.agent_soul, name=name)
- archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=skill.file_id)
+ file_id = self._available_skill_file_id(skill)
+ archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
try:
archive_items, skill_md = self._inspect_skill_archive(archive_bytes)
except (OSError, ValueError, zipfile.BadZipFile) as exc:
@@ -314,10 +317,11 @@ class AgentConfigService:
user_id=user_id,
)
skill = self._require_skill(target.agent_soul, name=name)
+ file_id = self._available_skill_file_id(skill)
member_path = self._normalize_archive_member_path(path)
payload = self._load_skill_archive_member(
tenant_id=tenant_id,
- file_id=skill.file_id,
+ file_id=file_id,
path=member_path,
)
return self._preview_bytes(path=member_path, size=len(payload), payload=payload)
@@ -341,10 +345,11 @@ class AgentConfigService:
user_id=user_id,
)
skill = self._require_skill(target.agent_soul, name=name)
+ file_id = self._available_skill_file_id(skill)
member_path = self._normalize_archive_member_path(path)
payload = self._load_skill_archive_member(
tenant_id=tenant_id,
- file_id=skill.file_id,
+ file_id=file_id,
path=member_path,
)
return ConfigDownload(
@@ -372,10 +377,11 @@ class AgentConfigService:
user_id=user_id,
)
skill = self._require_skill(target.agent_soul, name=name)
+ file_id = self._available_skill_file_id(skill)
member_path = self._normalize_archive_member_path(path)
self._load_skill_archive_member(
tenant_id=tenant_id,
- file_id=skill.file_id,
+ file_id=file_id,
path=member_path,
)
return member_path
@@ -398,10 +404,11 @@ class AgentConfigService:
user_id=user_id,
)
file_ref = self._require_file(target.agent_soul, name=name)
+ file_id = self._available_file_id(file_ref)
payload, filename, mime_type = self._load_file_ref_bytes(
tenant_id=tenant_id,
file_kind=file_ref.file_kind,
- file_id=file_ref.file_id,
+ file_id=file_id,
)
return ConfigDownload(
filename=filename or file_ref.name, mime_type=mime_type or "application/octet-stream", payload=payload
@@ -425,7 +432,8 @@ class AgentConfigService:
user_id=user_id,
)
file_ref = self._require_file(target.agent_soul, name=name)
- url = self._resolve_download_url(tenant_id=tenant_id, file_kind=file_ref.file_kind, file_id=file_ref.file_id)
+ file_id = self._available_file_id(file_ref)
+ url = self._resolve_download_url(tenant_id=tenant_id, file_kind=file_ref.file_kind, file_id=file_id)
if url is None:
raise AgentConfigServiceError("config_file_not_found", "config file payload is missing", status_code=404)
return url
@@ -660,10 +668,11 @@ class AgentConfigService:
user_id=user_id,
)
file_ref = self._require_file(target.agent_soul, name=name)
+ file_id = self._available_file_id(file_ref)
payload, filename, _mime_type = self._load_file_ref_bytes(
tenant_id=tenant_id,
file_kind=file_ref.file_kind,
- file_id=file_ref.file_id,
+ file_id=file_id,
)
return self._preview_bytes(
path=filename or file_ref.name, size=file_ref.size, payload=payload, field_name="name"
@@ -1154,6 +1163,7 @@ class AgentConfigService:
"id": skill.name,
"name": skill.name,
"file_id": skill.file_id,
+ "is_missing": skill.is_missing,
"description": skill.description,
"size": skill.size,
"hash": skill.hash,
@@ -1166,6 +1176,7 @@ class AgentConfigService:
"id": file_ref.name,
"name": file_ref.name,
"file_id": file_ref.file_id,
+ "is_missing": file_ref.is_missing,
"size": file_ref.size,
"hash": file_ref.hash,
"mime_type": file_ref.mime_type,
@@ -1284,6 +1295,12 @@ class AgentConfigService:
normalized = validate_config_skill_name(name)
for item in agent_soul.config_skills:
if item.name == normalized:
+ if item.is_missing or not item.file_id:
+ raise AgentConfigServiceError(
+ "config_skill_missing",
+ "config skill must be uploaded before it can be used",
+ status_code=409,
+ )
return item
raise AgentConfigServiceError("config_skill_not_found", "config skill not found", status_code=404)
@@ -1292,9 +1309,35 @@ class AgentConfigService:
normalized = validate_config_name(name)
for item in agent_soul.config_files:
if item.name == normalized:
+ if item.is_missing or not item.file_id:
+ raise AgentConfigServiceError(
+ "config_file_missing",
+ "config file must be uploaded before it can be used",
+ status_code=409,
+ )
return item
raise AgentConfigServiceError("config_file_not_found", "config file not found", status_code=404)
+ @staticmethod
+ def _available_skill_file_id(skill: AgentConfigSkillRefConfig) -> str:
+ if skill.is_missing or not skill.file_id:
+ raise AgentConfigServiceError(
+ "config_skill_missing",
+ "config skill must be uploaded before it can be used",
+ status_code=409,
+ )
+ return skill.file_id
+
+ @staticmethod
+ def _available_file_id(file_ref: AgentConfigFileRefConfig) -> str:
+ if file_ref.is_missing or not file_ref.file_id:
+ raise AgentConfigServiceError(
+ "config_file_missing",
+ "config file must be uploaded before it can be used",
+ status_code=409,
+ )
+ return file_ref.file_id
+
def _load_tool_file_bytes(self, *, tenant_id: str, file_id: str) -> tuple[bytes, str | None]:
with session_factory.create_session() as session:
tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id))
diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py
index 6a9d7979931..224c08384ad 100644
--- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py
+++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py
@@ -22,7 +22,8 @@ from core.trigger.constants import (
from extensions.ext_redis import redis_client
from graphon.enums import BuiltinNodeTypes
from models import Account, App, AppMode
-from models.agent import Agent, AgentConfigDraft, AgentConfigDraftType, AgentScope, AgentSource
+from models.agent import Agent, AgentConfigDraft, AgentConfigDraftType, AgentConfigSnapshot, AgentScope, AgentSource
+from models.agent_config_entities import AgentSoulConfig
from models.model import AppModelConfig, IconType
from services import app_dsl_service
from services.account_service import AccountService, TenantService
@@ -969,6 +970,43 @@ class TestAppDslService:
account,
session=db_session_with_containers,
)
+ source_agent = db_session_with_containers.scalar(
+ select(Agent).where(
+ Agent.tenant_id == account.current_tenant_id,
+ Agent.app_id == source_app.id,
+ Agent.scope == AgentScope.ROSTER,
+ )
+ )
+ assert source_agent is not None
+ source_snapshot = db_session_with_containers.scalar(
+ select(AgentConfigSnapshot).where(
+ AgentConfigSnapshot.agent_id == source_agent.id,
+ AgentConfigSnapshot.id == source_agent.active_config_snapshot_id,
+ )
+ )
+ assert source_snapshot is not None
+ source_snapshot.config_snapshot = AgentSoulConfig.model_validate(
+ {
+ "config_skills": [
+ {
+ "name": "research",
+ "description": "Research source material.",
+ "file_id": "source-skill-file-id",
+ "size": 123,
+ }
+ ],
+ "config_files": [
+ {
+ "name": "guide.md",
+ "file_kind": "upload_file",
+ "file_id": "source-config-file-id",
+ "size": 456,
+ "mime_type": "text/markdown",
+ }
+ ],
+ }
+ )
+ db_session_with_containers.commit()
yaml_content = AppDslService.export_dsl(
source_app,
@@ -979,13 +1017,42 @@ class TestAppDslService:
serialized_package = exported_data["agent_packages"][exported_data["agent"]["package_ref"]]
assert exported_data["app"]["mode"] == AppMode.AGENT.value
assert "agent_id" not in json.dumps(serialized_package)
+ assert serialized_package["soul"]["config_skills"] == [
+ {
+ "name": "research",
+ "description": "Research source material.",
+ "file_kind": "tool_file",
+ "file_id": "",
+ "is_missing": True,
+ "size": 123,
+ "hash": None,
+ "mime_type": "application/zip",
+ }
+ ]
+ assert serialized_package["soul"]["config_files"] == [
+ {
+ "name": "guide.md",
+ "file_kind": "upload_file",
+ "file_id": "",
+ "is_missing": True,
+ "size": 456,
+ "hash": None,
+ "mime_type": "text/markdown",
+ }
+ ]
+ assert "source-skill-file-id" not in yaml_content
+ assert "source-config-file-id" not in yaml_content
result = AppDslService(db_session_with_containers).import_app(
account=account,
import_mode=ImportMode.YAML_CONTENT,
yaml_content=yaml_content,
)
- assert result.status == ImportStatus.COMPLETED
+ assert result.status == ImportStatus.COMPLETED_WITH_WARNINGS
+ assert {warning.code for warning in result.warnings} == {
+ "agent_file_omitted",
+ "agent_skill_omitted",
+ }
assert result.app_id is not None
db_session_with_containers.commit()
@@ -1008,6 +1075,13 @@ class TestAppDslService:
)
assert draft is not None
assert draft.base_snapshot_id == imported_agent.active_config_snapshot_id
+ imported_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
+ assert imported_soul.config_skills[0].name == "research"
+ assert imported_soul.config_skills[0].file_id == ""
+ assert imported_soul.config_skills[0].is_missing is True
+ assert imported_soul.config_files[0].name == "guide.md"
+ assert imported_soul.config_files[0].file_id == ""
+ assert imported_soul.config_files[0].is_missing is True
def test_export_dsl_workflow_app_success(
self, db_session_with_containers: Session, mock_external_service_dependencies
diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py
index e25f5bea5bf..b026c0e0c85 100644
--- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py
+++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py
@@ -291,6 +291,11 @@ def test_agent_app_list_and_create_use_agent_route(
]
},
)
+ monkeypatch.setattr(
+ roster_controller.AgentRosterService,
+ "load_reference_counts_by_agent_id",
+ lambda _self, **kwargs: {"agent-list": 2},
+ )
monkeypatch.setattr(
roster_controller.AgentRosterService,
"load_or_create_agent_app_debug_conversation_ids_by_agent_id",
@@ -326,6 +331,7 @@ def test_agent_app_list_and_create_use_agent_route(
assert listed["data"][0]["debug_conversation_id"] == "debug-conversation-list"
assert listed["data"][0]["role"] == "List role"
assert listed["data"][0]["active_config_is_published"] is False
+ assert listed["data"][0]["reference_count"] == 2
assert listed["data"][0]["published_reference_count"] == 1
assert listed["data"][0]["published_references"] == [
{
diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
index 9d0089e8e08..811620e4005 100644
--- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
+++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
@@ -1553,6 +1553,29 @@ def test_build_config_layer_config_missing_mentions_warn_without_catalog():
assert [w["code"] for w in warnings] == ["mention_target_missing"]
+def test_build_config_layer_config_excludes_missing_assets_from_runtime():
+ from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
+
+ soul = AgentSoulConfig.model_validate(
+ {
+ "prompt": {"system_prompt": "Use [§skill:missing-skill:Missing Skill§]."},
+ "config_skills": [{"name": "missing-skill", "file_id": "", "is_missing": True}],
+ "config_files": [{"name": "missing.txt", "file_kind": "upload_file", "file_id": "", "is_missing": True}],
+ }
+ )
+
+ config, warnings = build_config_layer_config(soul)
+
+ assert config.skills == []
+ assert config.files == []
+ assert config.mentioned_skill_names == []
+ assert [warning["code"] for warning in warnings] == [
+ "config_asset_missing",
+ "config_asset_missing",
+ "mention_target_missing",
+ ]
+
+
# ── ENG-635: ask_human layer gating + feature manifest ───────────────────────
diff --git a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py
index 926a04f53f9..00aecefbaa6 100644
--- a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py
+++ b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py
@@ -129,8 +129,12 @@ def test_make_portable_agent_package_strips_workspace_credentials_and_assets() -
assert package.soul.tools.dify_tools[0].credential_ref is None
assert package.soul.tools.dify_tools[0].runtime_parameters["upload_file_id"] is None
assert package.soul.tools.dify_tools[0].runtime_parameters["api_key"] is None
- assert package.soul.config_skills == []
- assert package.soul.config_files == []
+ assert package.soul.config_skills[0].name == "research"
+ assert package.soul.config_skills[0].file_id == ""
+ assert package.soul.config_skills[0].is_missing is True
+ assert package.soul.config_files[0].name == "guide.md"
+ assert package.soul.config_files[0].file_id == ""
+ assert package.soul.config_files[0].is_missing is True
assert [asset.kind for asset in package.omitted_assets] == ["skill", "file"]
assert "plain-secret" not in str(serialized)
assert "model-secret" not in str(serialized)
@@ -530,7 +534,7 @@ def test_create_imported_roster_agent_app_prefixes_warnings(monkeypatch) -> None
assert app.name == "Portable Agent"
assert app.enable_site is True
assert app.enable_api is True
- send.assert_called_once_with(app, account=SimpleNamespace(id="account-1"))
+ send.assert_called_once_with(app, account=SimpleNamespace(id="account-1"), session=session)
assert imported.warnings[0].path == "agent_packages.agent_1.soul.model"
@@ -627,6 +631,25 @@ def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(mon
assert datasets[0].id == "existing"
assert datasets[1].id is not None
assert datasets[1].id.startswith("missing-dataset-")
+ assert resolved.config_skills[0].model_dump(mode="json") == {
+ "name": "skill",
+ "description": "",
+ "file_kind": "tool_file",
+ "file_id": "",
+ "is_missing": True,
+ "size": None,
+ "hash": None,
+ "mime_type": "application/zip",
+ }
+ assert resolved.config_files[0].model_dump(mode="json") == {
+ "name": "Guide",
+ "file_kind": "upload_file",
+ "file_id": "",
+ "is_missing": True,
+ "size": None,
+ "hash": None,
+ "mime_type": None,
+ }
assert {warning.code for warning in warnings} == {
"agent_skill_omitted",
"agent_file_omitted",
diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py
index e19fa36dca9..eb8f87fc1a9 100644
--- a/api/tests/unit_tests/services/agent/test_agent_services.py
+++ b/api/tests/unit_tests/services/agent/test_agent_services.py
@@ -2284,6 +2284,7 @@ def test_roster_list_and_invite_options(monkeypatch: pytest.MonkeyPatch):
lambda version_ids: {"version-1": version, "version-2": unconfigured_version},
)
monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {})
+ monkeypatch.setattr(service, "_load_reference_counts_by_agent_id", lambda **kwargs: {"agent-1": 1})
listed = service.list_roster_agents(tenant_id="tenant-1", page=1, limit=20)
invited = service.list_invite_options(tenant_id="tenant-1", page=1, limit=20, app_id="app-1")
@@ -2297,6 +2298,7 @@ def test_roster_list_and_invite_options(monkeypatch: pytest.MonkeyPatch):
assert listed["data"][0]["updated_at"] == int(updated_at.timestamp())
assert listed["data"][0]["active_config_snapshot"]["created_at"] == int(version_created_at.timestamp())
assert listed["data"][0]["active_config_is_published"] is True
+ assert listed["data"][0]["reference_count"] == 1
assert listed["data"][1]["active_config_is_published"] is False
assert invited["data"][0]["is_in_current_workflow"] is True
assert invited["data"][0]["existing_node_ids"] == ["node-1"]
@@ -2327,6 +2329,7 @@ def test_invite_options_uses_db_filtered_pagination(monkeypatch: pytest.MonkeyPa
},
)
monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {})
+ monkeypatch.setattr(service, "_load_reference_counts_by_agent_id", lambda **kwargs: {})
result = service.list_invite_options(tenant_id="tenant-1", page=1, limit=1)
@@ -2498,6 +2501,43 @@ def test_published_references_include_app_display_fields_and_sort_by_updated_at(
assert references[0]["workflow_version"] == "published-recent"
+def test_reference_counts_include_draft_and_published_bindings_once_per_app():
+ bindings = [
+ SimpleNamespace(
+ agent_id="agent-1",
+ app_id="app-1",
+ workflow_id="workflow-draft",
+ workflow_version=Workflow.VERSION_DRAFT,
+ ),
+ SimpleNamespace(
+ agent_id="agent-1",
+ app_id="app-1",
+ workflow_id="workflow-published",
+ workflow_version="v1",
+ ),
+ SimpleNamespace(
+ agent_id="agent-1",
+ app_id="app-2",
+ workflow_id="workflow-stale",
+ workflow_version="old-version",
+ ),
+ ]
+ apps = [
+ SimpleNamespace(id="app-1", workflow_id="workflow-published"),
+ SimpleNamespace(id="app-2", workflow_id="workflow-stale"),
+ ]
+ workflows = [
+ SimpleNamespace(id="workflow-draft", app_id="app-1", version=Workflow.VERSION_DRAFT),
+ SimpleNamespace(id="workflow-published", app_id="app-1", version="v1"),
+ SimpleNamespace(id="workflow-stale", app_id="app-2", version="current-version"),
+ ]
+ service = AgentRosterService(FakeSession(scalars=[bindings, apps, workflows]))
+
+ result = service._load_reference_counts_by_agent_id(tenant_id="tenant-1", agent_ids=["agent-1"])
+
+ assert result == {"agent-1": 1}
+
+
def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPatch):
listed_version = AgentConfigSnapshot(id="version-4", agent_id="agent-1", version=4)
listed_version_created_at = datetime(2026, 1, 5, 3, 4, 5, tzinfo=UTC)
@@ -3933,6 +3973,28 @@ class TestWorkflowAgentDraftBindingSync:
draft_workflow=self._agent_workflow(),
)
+ def test_publish_validation_rejects_missing_config_assets(self):
+ payload = ComposerSavePayload.model_validate(
+ {
+ "variant": "agent_app",
+ "save_strategy": "save_as_new_version",
+ "agent_soul": {
+ "config_skills": [{"name": "research", "file_id": "", "is_missing": True}],
+ "config_files": [
+ {
+ "name": "guide.txt",
+ "file_kind": "upload_file",
+ "file_id": "",
+ "is_missing": True,
+ }
+ ],
+ },
+ }
+ )
+
+ with pytest.raises(InvalidComposerConfigError, match="config_asset_missing.*skill:research.*file:guide.txt"):
+ ComposerConfigValidator.validate_publish_payload(payload)
+
def test_projects_binding_declared_outputs_to_draft_graph_response(self):
workflow = Workflow(
id="workflow-1",
diff --git a/api/tests/unit_tests/services/test_agent_config_service.py b/api/tests/unit_tests/services/test_agent_config_service.py
index f9a649a13ab..4b1313be7c6 100644
--- a/api/tests/unit_tests/services/test_agent_config_service.py
+++ b/api/tests/unit_tests/services/test_agent_config_service.py
@@ -13,6 +13,7 @@ from models.agent_config_entities import (
AgentConfigFileRefConfig,
AgentConfigSkillRefConfig,
AgentEnvVariableConfig,
+ AgentFileRefConfig,
AgentSoulConfig,
)
from services.agent.skill_package_service import SkillPackageError
@@ -211,7 +212,14 @@ def test_push_for_console_allows_shared_draft_mutations() -> None:
def test_push_accepts_tenant_scoped_tool_file_sources_from_different_upload_owner() -> None:
session = MagicMock()
service = AgentConfigService()
- target = _target(kind=AgentConfigVersionKind.BUILD_DRAFT, writable=True)
+ target = _target(
+ kind=AgentConfigVersionKind.BUILD_DRAFT,
+ writable=True,
+ soul=_soul(
+ config_skills=[{"name": "alpha", "file_id": "", "is_missing": True}],
+ config_files=[{"name": "guide.txt", "file_kind": "tool_file", "file_id": "", "is_missing": True}],
+ ),
+ )
file_source = SimpleNamespace(
id="tool-file-file",
tenant_id=TENANT,
@@ -269,7 +277,9 @@ def test_push_accepts_tenant_scoped_tool_file_sources_from_different_upload_owne
assert isinstance(files, dict)
assert isinstance(skills, dict)
assert files["items"][0]["file_id"] == "tool-file-file"
+ assert files["items"][0]["is_missing"] is False
assert skills["items"][0]["file_id"] == "normalized-skill-file"
+ assert skills["items"][0]["is_missing"] is False
session.commit.assert_called_once()
@@ -301,7 +311,11 @@ def test_push_file_for_console_rejects_snapshot_writes() -> None:
def test_push_file_for_console_uses_service_owned_upload_lookup_and_naming() -> None:
session = MagicMock()
service = AgentConfigService()
- target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False)
+ target = _target(
+ kind=AgentConfigVersionKind.DRAFT,
+ writable=False,
+ soul=_soul(config_files=[{"name": "guide.txt", "file_kind": "upload_file", "file_id": "", "is_missing": True}]),
+ )
upload_file = SimpleNamespace(
id="upload-1",
name="guide.txt",
@@ -329,6 +343,7 @@ def test_push_file_for_console_uses_service_owned_upload_lookup_and_naming() ->
"id": "guide.txt",
"name": "guide.txt",
"file_id": "upload-1",
+ "is_missing": False,
"size": 7,
"hash": "sha256:abc",
"mime_type": "text/plain",
@@ -508,6 +523,7 @@ def test_manifest_uses_items_shape_without_download_urls() -> None:
"id": "alpha",
"name": "alpha",
"file_id": "tool-file-1",
+ "is_missing": False,
"description": "Alpha skill",
"size": None,
"hash": None,
@@ -521,6 +537,7 @@ def test_manifest_uses_items_shape_without_download_urls() -> None:
"id": "guide.txt",
"name": "guide.txt",
"file_id": "upload-file-1",
+ "is_missing": False,
"size": None,
"hash": None,
"mime_type": None,
@@ -532,6 +549,63 @@ def test_manifest_uses_items_shape_without_download_urls() -> None:
}
+def test_manifest_preserves_missing_config_assets_and_pull_rejects_them() -> None:
+ soul = _soul(
+ config_skills=[{"name": "alpha", "file_id": "", "is_missing": True}],
+ config_files=[{"name": "guide.txt", "file_kind": "upload_file", "file_id": "", "is_missing": True}],
+ )
+ target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False, soul=soul)
+ service = AgentConfigService()
+
+ manifest = service._manifest_for_target(target)
+
+ assert manifest["skills"]["items"][0]["is_missing"] is True # type: ignore[index]
+ assert manifest["files"]["items"][0]["is_missing"] is True # type: ignore[index]
+ with patch.object(service, "resolve_target", return_value=target):
+ with pytest.raises(AgentConfigServiceError) as skill_error:
+ service.pull_skill(
+ tenant_id=TENANT,
+ agent_id=AGENT,
+ config_version_id="draft-1",
+ config_version_kind=AgentConfigVersionKind.DRAFT,
+ name="alpha",
+ user_id=USER,
+ )
+ with pytest.raises(AgentConfigServiceError) as file_error:
+ service.pull_file(
+ tenant_id=TENANT,
+ agent_id=AGENT,
+ config_version_id="draft-1",
+ config_version_kind=AgentConfigVersionKind.DRAFT,
+ name="guide.txt",
+ user_id=USER,
+ )
+
+ assert (skill_error.value.code, skill_error.value.status_code) == ("config_skill_missing", 409)
+ assert (file_error.value.code, file_error.value.status_code) == ("config_file_missing", 409)
+
+
+def test_config_asset_refs_require_file_id_unless_marked_missing() -> None:
+ assert AgentFileRefConfig().file_id is None
+ assert (
+ AgentConfigFileRefConfig(
+ name="guide.txt",
+ file_kind="upload_file",
+ is_missing=True,
+ ).file_id
+ == ""
+ )
+ with pytest.raises(ValueError, match="file_id is required"):
+ AgentConfigSkillRefConfig(name="alpha")
+ with pytest.raises(ValueError, match="must not retain"):
+ AgentConfigFileRefConfig(
+ name="guide.txt",
+ file_kind="upload_file",
+ file_id="workspace-file-id",
+ is_missing=True,
+ )
+
+
def test_preview_skill_file_returns_text_preview() -> None:
service = AgentConfigService()
target = _target(
diff --git a/dify-agent-runtime/README.md b/dify-agent-runtime/README.md
index 74e796a1d5c..7abc105211f 100644
--- a/dify-agent-runtime/README.md
+++ b/dify-agent-runtime/README.md
@@ -67,23 +67,23 @@ make test
Each agent job runs inside a Landlock sandbox that restricts filesystem access:
-| Access | Paths (defaults) |
-|--------|------------------|
-| **Read-Write** | `$HOME` (always, includes `$CWD/.tmp` as `TMPDIR`) |
-| **Read-Write (dev)** | `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/random`, `/dev/tty` |
+| Access | Paths (defaults) |
+| -------------------- | ------------------------------------------------------------------------------------------------------------- |
+| **Read-Write** | `$HOME` (always, includes `$CWD/.tmp` as `TMPDIR`) |
+| **Read-Write (dev)** | `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/random`, `/dev/tty` |
| **Read-Only + Exec** | `/usr`, `/bin`, `/sbin`, `/lib`, `/lib64`, `/etc`, `/proc`, `/opt/dify-agent-tools`, `/opt/homebrew`, `/snap` |
-| **Denied** | Everything else (`/tmp`, other agents' homes, `/var`, `/srv`, etc.) |
+| **Denied** | Everything else (`/tmp`, other agents' homes, `/var`, `/srv`, etc.) |
The runner automatically creates `$CWD/.tmp` and sets `TMPDIR`, `TMP`, `TEMP` to it, so temp files stay isolated per workspace.
### Environment Variables
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `ENABLE_PATH_ISOLATION` | `true` | Set to `false` to disable Landlock entirely |
-| `LANDLOCK_RW_PATHS` | *(empty)* | Comma-separated RW directories (besides `$HOME`) |
-| `LANDLOCK_RO_PATHS` | `/usr,/bin,...` | Comma-separated RO+exec directories |
-| `LANDLOCK_RW_DEV_PATHS` | `/dev/null,...` | Comma-separated device files with RW access |
+| Variable | Default | Description |
+| ----------------------- | --------------- | ------------------------------------------------ |
+| `ENABLE_PATH_ISOLATION` | `true` | Set to `false` to disable Landlock entirely |
+| `LANDLOCK_RW_PATHS` | _(empty)_ | Comma-separated RW directories (besides `$HOME`) |
+| `LANDLOCK_RO_PATHS` | `/usr,/bin,...` | Comma-separated RO+exec directories |
+| `LANDLOCK_RW_DEV_PATHS` | `/dev/null,...` | Comma-separated device files with RW access |
Requires Linux ≥ 5.13. On unsupported kernels, a warning is printed to stderr.
diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts
index ec29f5662b0..794f5687d84 100644
--- a/packages/contracts/generated/api/console/agent/types.gen.ts
+++ b/packages/contracts/generated/api/console/agent/types.gen.ts
@@ -519,6 +519,7 @@ export type AgentAppPartial = {
permission_keys?: Array
published_reference_count?: number
published_references?: Array
+ reference_count?: number | null
role?: string | null
tags?: Array
updated_at?: number | null
@@ -610,6 +611,7 @@ export type AgentInviteOptionResponse = {
published_node_reference_count?: number
published_reference_count?: number
published_references?: Array
+ reference_count?: number | null
role?: string
scope: AgentScope
source: AgentSource
@@ -757,6 +759,7 @@ export type AgentConfigFileItemResponse = {
file_id?: string | null
hash?: string | null
id: string
+ is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
@@ -775,6 +778,7 @@ export type AgentConfigSkillItemResponse = {
file_id?: string | null
hash?: string | null
id: string
+ is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
@@ -1134,9 +1138,10 @@ export type AppVariableConfig = {
}
export type AgentConfigFileRefConfig = {
- file_id: string
+ file_id?: string
file_kind: 'tool_file' | 'upload_file'
hash?: string | null
+ is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
@@ -1144,9 +1149,10 @@ export type AgentConfigFileRefConfig = {
export type AgentConfigSkillRefConfig = {
description?: string
- file_id: string
+ file_id?: string
file_kind?: 'tool_file'
hash?: string | null
+ is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
@@ -1919,6 +1925,7 @@ export type AgentAppPartialWritable = {
permission_keys?: Array
published_reference_count?: number
published_references?: Array
+ reference_count?: number | null
role?: string | null
tags?: Array
updated_at?: number | null
diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts
index 0687f741c6d..a7d905f1fb4 100644
--- a/packages/contracts/generated/api/console/agent/zod.gen.ts
+++ b/packages/contracts/generated/api/console/agent/zod.gen.ts
@@ -461,6 +461,7 @@ export const zAgentConfigFileItemResponse = z.object({
file_id: z.string().nullish(),
hash: z.string().nullish(),
id: z.string(),
+ is_missing: z.boolean().optional().default(false),
mime_type: z.string().nullish(),
name: z.string(),
size: z.int().nullish(),
@@ -498,6 +499,7 @@ export const zAgentConfigSkillItemResponse = z.object({
file_id: z.string().nullish(),
hash: z.string().nullish(),
id: z.string(),
+ is_missing: z.boolean().optional().default(false),
mime_type: z.string().nullish(),
name: z.string(),
size: z.int().nullish(),
@@ -983,6 +985,7 @@ export const zAgentAppPartial = z.object({
permission_keys: z.array(z.string()).optional(),
published_reference_count: z.int().optional().default(0),
published_references: z.array(zAgentAppPublishedReferenceResponse).optional(),
+ reference_count: z.int().nullish(),
role: z.string().nullish(),
tags: z.array(zTag).optional(),
updated_at: z.int().nullish(),
@@ -1134,6 +1137,7 @@ export const zAgentInviteOptionResponse = z.object({
published_node_reference_count: z.int().optional().default(0),
published_reference_count: z.int().optional().default(0),
published_references: z.array(zAgentPublishedReferenceResponse).optional(),
+ reference_count: z.int().nullish(),
role: z.string().optional().default(''),
scope: zAgentScope,
source: zAgentSource,
@@ -1191,9 +1195,10 @@ export const zAppVariableConfig = z.object({
* Stable Agent Soul reference to one config file payload.
*/
export const zAgentConfigFileRefConfig = z.object({
- file_id: z.string().min(1).max(255),
+ file_id: z.string().max(255).optional().default(''),
file_kind: z.enum(['tool_file', 'upload_file']),
hash: z.string().nullish(),
+ is_missing: z.boolean().optional().default(false),
mime_type: z.string().nullish(),
name: z.string().min(1).max(255),
size: z.int().nullish(),
@@ -1206,9 +1211,10 @@ export const zAgentConfigFileRefConfig = z.object({
*/
export const zAgentConfigSkillRefConfig = z.object({
description: z.string().optional().default(''),
- file_id: z.string().min(1).max(255),
+ file_id: z.string().max(255).optional().default(''),
file_kind: z.literal('tool_file').optional().default('tool_file'),
hash: z.string().nullish(),
+ is_missing: z.boolean().optional().default(false),
mime_type: z.string().nullish().default('application/zip'),
name: z.string().min(1).max(255),
size: z.int().nullish(),
@@ -2634,6 +2640,7 @@ export const zAgentAppPartialWritable = z.object({
permission_keys: z.array(z.string()).optional(),
published_reference_count: z.int().optional().default(0),
published_references: z.array(zAgentAppPublishedReferenceResponse).optional(),
+ reference_count: z.int().nullish(),
role: z.string().nullish(),
tags: z.array(zTag).optional(),
updated_at: z.int().nullish(),
diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts
index afc4c5edf03..404b3afe0b6 100644
--- a/packages/contracts/generated/api/console/apps/types.gen.ts
+++ b/packages/contracts/generated/api/console/apps/types.gen.ts
@@ -1431,6 +1431,7 @@ export type AgentConfigFileItemResponse = {
file_id?: string | null
hash?: string | null
id: string
+ is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
@@ -1449,6 +1450,7 @@ export type AgentConfigSkillItemResponse = {
file_id?: string | null
hash?: string | null
id: string
+ is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
@@ -2396,9 +2398,10 @@ export type AppVariableConfig = {
}
export type AgentConfigFileRefConfig = {
- file_id: string
+ file_id?: string
file_kind: 'tool_file' | 'upload_file'
hash?: string | null
+ is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
@@ -2406,9 +2409,10 @@ export type AgentConfigFileRefConfig = {
export type AgentConfigSkillRefConfig = {
description?: string
- file_id: string
+ file_id?: string
file_kind?: 'tool_file'
hash?: string | null
+ is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts
index 5625ee8c10e..97b9e281afd 100644
--- a/packages/contracts/generated/api/console/apps/zod.gen.ts
+++ b/packages/contracts/generated/api/console/apps/zod.gen.ts
@@ -1014,6 +1014,7 @@ export const zAgentConfigFileItemResponse = z.object({
file_id: z.string().nullish(),
hash: z.string().nullish(),
id: z.string(),
+ is_missing: z.boolean().optional().default(false),
mime_type: z.string().nullish(),
name: z.string(),
size: z.int().nullish(),
@@ -1051,6 +1052,7 @@ export const zAgentConfigSkillItemResponse = z.object({
file_id: z.string().nullish(),
hash: z.string().nullish(),
id: z.string(),
+ is_missing: z.boolean().optional().default(false),
mime_type: z.string().nullish(),
name: z.string(),
size: z.int().nullish(),
@@ -2832,9 +2834,10 @@ export const zAppVariableConfig = z.object({
* Stable Agent Soul reference to one config file payload.
*/
export const zAgentConfigFileRefConfig = z.object({
- file_id: z.string().min(1).max(255),
+ file_id: z.string().max(255).optional().default(''),
file_kind: z.enum(['tool_file', 'upload_file']),
hash: z.string().nullish(),
+ is_missing: z.boolean().optional().default(false),
mime_type: z.string().nullish(),
name: z.string().min(1).max(255),
size: z.int().nullish(),
@@ -2847,9 +2850,10 @@ export const zAgentConfigFileRefConfig = z.object({
*/
export const zAgentConfigSkillRefConfig = z.object({
description: z.string().optional().default(''),
- file_id: z.string().min(1).max(255),
+ file_id: z.string().max(255).optional().default(''),
file_kind: z.literal('tool_file').optional().default('tool_file'),
hash: z.string().nullish(),
+ is_missing: z.boolean().optional().default(false),
mime_type: z.string().nullish().default('application/zip'),
name: z.string().min(1).max(255),
size: z.int().nullish(),
diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts
index 8fb46ea24ba..4776fda89f5 100644
--- a/packages/contracts/generated/api/console/snippets/types.gen.ts
+++ b/packages/contracts/generated/api/console/snippets/types.gen.ts
@@ -574,9 +574,10 @@ export type AppVariableConfig = {
}
export type AgentConfigFileRefConfig = {
- file_id: string
+ file_id?: string
file_kind: 'tool_file' | 'upload_file'
hash?: string | null
+ is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
@@ -584,9 +585,10 @@ export type AgentConfigFileRefConfig = {
export type AgentConfigSkillRefConfig = {
description?: string
- file_id: string
+ file_id?: string
file_kind?: 'tool_file'
hash?: string | null
+ is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts
index 06b8dac4e6f..d0b1a2b6169 100644
--- a/packages/contracts/generated/api/console/snippets/zod.gen.ts
+++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts
@@ -568,9 +568,10 @@ export const zAppVariableConfig = z.object({
* Stable Agent Soul reference to one config file payload.
*/
export const zAgentConfigFileRefConfig = z.object({
- file_id: z.string().min(1).max(255),
+ file_id: z.string().max(255).optional().default(''),
file_kind: z.enum(['tool_file', 'upload_file']),
hash: z.string().nullish(),
+ is_missing: z.boolean().optional().default(false),
mime_type: z.string().nullish(),
name: z.string().min(1).max(255),
size: z.int().nullish(),
@@ -583,9 +584,10 @@ export const zAgentConfigFileRefConfig = z.object({
*/
export const zAgentConfigSkillRefConfig = z.object({
description: z.string().optional().default(''),
- file_id: z.string().min(1).max(255),
+ file_id: z.string().max(255).optional().default(''),
file_kind: z.literal('tool_file').optional().default('tool_file'),
hash: z.string().nullish(),
+ is_missing: z.boolean().optional().default(false),
mime_type: z.string().nullish().default('application/zip'),
name: z.string().min(1).max(255),
size: z.int().nullish(),