mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
fix(agent): preserve DSL asset placeholders and count draft references (#39015)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
dcbedbd3bc
commit
73c3048599
@ -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"] = [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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, <br>**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, <br>**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 |
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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}:
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"] == [
|
||||
{
|
||||
|
||||
@ -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 ───────────────────────
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -519,6 +519,7 @@ export type AgentAppPartial = {
|
||||
permission_keys?: Array<string>
|
||||
published_reference_count?: number
|
||||
published_references?: Array<AgentAppPublishedReferenceResponse>
|
||||
reference_count?: number | null
|
||||
role?: string | null
|
||||
tags?: Array<Tag>
|
||||
updated_at?: number | null
|
||||
@ -610,6 +611,7 @@ export type AgentInviteOptionResponse = {
|
||||
published_node_reference_count?: number
|
||||
published_reference_count?: number
|
||||
published_references?: Array<AgentPublishedReferenceResponse>
|
||||
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<string>
|
||||
published_reference_count?: number
|
||||
published_references?: Array<AgentAppPublishedReferenceResponse>
|
||||
reference_count?: number | null
|
||||
role?: string | null
|
||||
tags?: Array<Tag>
|
||||
updated_at?: number | null
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user