fix(agent): align config detail and output contracts (#37535)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-06-17 09:17:20 +08:00 committed by GitHub
parent 7cb4a30040
commit bacc48d16e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 603 additions and 21 deletions

View File

@ -95,7 +95,8 @@ def _serialize_agent_app_detail(app_model) -> dict:
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
agent = _agent_roster_service().get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=app_model.id)
roster_service = _agent_roster_service()
agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=app_model.id)
if not agent:
raise AgentNotFoundError()
payload = AppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
@ -103,15 +104,24 @@ def _serialize_agent_app_detail(app_model) -> dict:
payload["app_id"] = str(app_model.id)
payload["id"] = agent.id
payload["role"] = agent.role or ""
payload["active_config_is_published"] = roster_service.active_config_is_published(
tenant_id=app_model.tenant_id,
agent=agent,
)
return payload
def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
app_ids = [str(app.id) for app in app_pagination.items]
agents_by_app_id = _agent_roster_service().load_app_backing_agents_by_app_id(
roster_service = _agent_roster_service()
agents_by_app_id = roster_service.load_app_backing_agents_by_app_id(
tenant_id=tenant_id,
app_ids=app_ids,
)
active_config_is_published_by_agent_id = roster_service.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=list(agents_by_app_id.values()),
)
payload = AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json")
for item in payload["data"]:
app_id = item["id"]
@ -121,6 +131,7 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
item["app_id"] = app_id
item["id"] = agent.id
item["role"] = agent.role or ""
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
return payload

View File

@ -403,6 +403,7 @@ class AppPartial(ResponseModel):
# For Agent App responses exposed through /agent.
app_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
is_starred: bool = False
@computed_field(return_type=str | None) # type: ignore
@ -457,6 +458,7 @@ class AppDetailWithSite(AppDetail):
# For Agent App responses exposed through /agent.
app_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
@computed_field(return_type=str | None) # type: ignore
@property

View File

@ -42,6 +42,7 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
@ -395,7 +396,11 @@ class WorkflowAgentRuntimeRequestBuilder:
@staticmethod
def _schema_for_declared_output(output: DeclaredOutputConfig) -> dict[str, Any]:
schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(output.type, array_item=output.array_item)
schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(
output.type,
array_item=output.array_item,
children=output.children,
)
if output.description:
schema["description"] = output.description
return schema
@ -405,6 +410,7 @@ class WorkflowAgentRuntimeRequestBuilder:
output_type: DeclaredOutputType,
*,
array_item: DeclaredArrayItem | None = None,
children: Sequence[DeclaredOutputChildConfig] | None = None,
) -> dict[str, Any]:
match output_type:
case DeclaredOutputType.STRING:
@ -414,18 +420,23 @@ class WorkflowAgentRuntimeRequestBuilder:
case DeclaredOutputType.BOOLEAN:
return {"type": "boolean"}
case DeclaredOutputType.OBJECT:
return {"type": "object"}
object_schema: dict[str, Any] = {"type": "object"}
WorkflowAgentRuntimeRequestBuilder._apply_child_properties(object_schema, children or [])
return object_schema
case DeclaredOutputType.ARRAY:
# Stage 4 §4.2: items shape mirrors the declared array_item.
# Validator guarantees array_item is set when type is array.
item_type = array_item.type if array_item else DeclaredOutputType.OBJECT
schema: dict[str, Any] = {
array_schema: dict[str, Any] = {
"type": "array",
"items": WorkflowAgentRuntimeRequestBuilder._schema_for_type(item_type),
"items": WorkflowAgentRuntimeRequestBuilder._schema_for_type(
item_type,
children=array_item.children if array_item else None,
),
}
if array_item is not None and array_item.description:
schema["items"]["description"] = array_item.description
return schema
array_schema["items"]["description"] = array_item.description
return array_schema
case DeclaredOutputType.FILE:
return {
"oneOf": [
@ -469,6 +480,27 @@ class WorkflowAgentRuntimeRequestBuilder:
}
assert_never(output_type)
@staticmethod
def _apply_child_properties(schema: dict[str, Any], children: Sequence[DeclaredOutputChildConfig]) -> None:
if not children:
return
properties: dict[str, Any] = {}
required: list[str] = []
for child in children:
child_schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(
child.type,
array_item=child.array_item,
children=child.children,
)
if child.description:
child_schema["description"] = child.description
properties[child.name] = child_schema
if child.required:
required.append(child.name)
schema["properties"] = properties
if required:
schema["required"] = required
@staticmethod
def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]:
normalized: dict[str, str | int | float | bool | None] = {}
@ -669,7 +701,13 @@ def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None:
name = _name_from_mapping(data)
if name is None:
return None
ref = data.get("ref") or data.get("id") or data.get("credential_id") or data.get("provider_credential_id")
ref = (
data.get("ref")
or data.get("value")
or data.get("id")
or data.get("credential_id")
or data.get("provider_credential_id")
)
return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None)

View File

@ -70,6 +70,7 @@ class AgentRosterResponse(ResponseModel):
workflow_node_id: str | None = None
active_config_snapshot_id: str | None = None
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
active_config_is_published: bool = False
status: AgentStatus
created_by: str | None = None
updated_by: str | None = None

View File

@ -2,9 +2,9 @@ from __future__ import annotations
import re
from enum import StrEnum
from typing import Any, Final, Literal
from typing import Annotated, Any, Final, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator
from core.workflow.file_reference import is_canonical_file_reference
from graphon.file import FileTransferMethod
@ -29,6 +29,44 @@ class DeclaredOutputType(StrEnum):
FILE = "file"
_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA = {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"type": {
"type": "string",
"enum": [item.value for item in DeclaredOutputType],
},
"description": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"required": {"type": "boolean"},
"file": {"type": "object", "additionalProperties": True},
"array_item": {
"type": "object",
"additionalProperties": True,
"properties": {
"type": {
"type": "string",
"enum": [item.value for item in DeclaredOutputType],
},
"description": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"children": {"type": "array", "items": {"type": "object", "additionalProperties": True}},
},
},
"children": {"type": "array", "items": {"type": "object", "additionalProperties": True}},
},
"required": ["name", "type"],
},
}
DeclaredOutputChildren = Annotated[
list["DeclaredOutputChildConfig"],
WithJsonSchema(_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA),
]
class AgentCliToolAuthorizationStatus(StrEnum):
"""Authorization state for Agent-scoped CLI tools.
@ -148,6 +186,9 @@ class AgentSecretRefConfig(AgentFlexibleConfig):
env_name: str | None = Field(default=None, max_length=255)
variable: str | None = Field(default=None, max_length=255)
type: str | None = Field(default=None, max_length=64)
# UI-facing selected secret reference. This is a credential/ref id, not the
# plaintext secret value; runtime maps it to the shell-layer ``ref``.
value: str | None = Field(default=None, max_length=255)
id: str | None = Field(default=None, max_length=255)
ref: str | None = Field(default=None, max_length=255)
credential_id: str | None = Field(default=None, max_length=255)
@ -507,11 +548,55 @@ class DeclaredArrayItem(BaseModel):
type: DeclaredOutputType
description: str | None = None
children: DeclaredOutputChildren = Field(default_factory=list)
@model_validator(mode="after")
def _reject_nested_array(self) -> DeclaredArrayItem:
if self.type == DeclaredOutputType.ARRAY:
raise ValueError("nested arrays are not supported as array_item.type")
if self.children and self.type != DeclaredOutputType.OBJECT:
raise ValueError("array_item.children is only allowed when array_item.type is object")
return self
class DeclaredOutputChildConfig(BaseModel):
"""Nested field under an object-shaped declared output.
The first backend version keeps child fields lightweight: they describe the
variable-picker/schema tree but do not own independent retry/check behavior.
"""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
type: DeclaredOutputType
description: str | None = None
required: bool = True
file: DeclaredOutputFileConfig | None = None
array_item: DeclaredArrayItem | None = None
children: DeclaredOutputChildren = Field(default_factory=list)
@model_validator(mode="after")
def _validate_shape(self) -> DeclaredOutputChildConfig:
if not _OUTPUT_NAME_PATTERN.fullmatch(self.name):
raise ValueError(
f"output child name {self.name!r} must match {_OUTPUT_NAME_PATTERN.pattern} "
"(JSON-schema-friendly identifier)"
)
if self.type == DeclaredOutputType.FILE:
if self.file is None:
self.file = DeclaredOutputFileConfig()
elif self.file is not None:
raise ValueError("file metadata is only allowed for file output children")
if self.type == DeclaredOutputType.ARRAY:
if self.array_item is None:
self.array_item = DeclaredArrayItem(type=DeclaredOutputType.OBJECT)
elif self.array_item is not None:
raise ValueError("array_item is only allowed when child type is array")
if self.children and self.type != DeclaredOutputType.OBJECT:
raise ValueError("children is only allowed for object output children")
return self
@ -592,6 +677,7 @@ class DeclaredOutputConfig(BaseModel):
required: bool = True
file: DeclaredOutputFileConfig | None = None
array_item: DeclaredArrayItem | None = None
children: DeclaredOutputChildren = Field(default_factory=list)
check: DeclaredOutputCheckConfig | None = None
failure_strategy: DeclaredOutputFailureStrategy = Field(default_factory=DeclaredOutputFailureStrategy)
@ -625,6 +711,9 @@ class DeclaredOutputConfig(BaseModel):
elif self.array_item is not None:
raise ValueError("array_item is only allowed when type is array")
if self.children and self.type != DeclaredOutputType.OBJECT:
raise ValueError("children is only allowed for object outputs")
# Per PRD §OUTPUT 配置框: output check is file-only.
if self.check is not None and self.check.enabled and self.type != DeclaredOutputType.FILE:
raise ValueError("output check is only allowed for file outputs")

View File

@ -11703,6 +11703,7 @@ Supported icon storage formats for Agent roster entries.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_is_published | boolean | | No |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot_id | string | | No |
| agent_kind | [AgentKind](#agentkind) | | Yes |
@ -11924,6 +11925,7 @@ the current roster/workflow APIs scoped to Dify Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_is_published | boolean | | No |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot_id | string | | No |
| agent_kind | [AgentKind](#agentkind) | | Yes |
@ -11989,6 +11991,7 @@ Visibility and lifecycle scope of an Agent record.
| provider_credential_id | string | | No |
| ref | string | | No |
| type | string | | No |
| value | string | | No |
| variable | string | | No |
#### AgentSensitiveWordAvoidanceFeatureConfig
@ -12515,6 +12518,7 @@ Enum class for api provider schema type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| access_mode | string | | No |
| active_config_is_published | boolean | | No |
| api_base_url | string | | No |
| app_id | string | | No |
| bound_agent_id | string | | No |
@ -12638,6 +12642,7 @@ AppMCPServer Status Enum
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| access_mode | string | | No |
| active_config_is_published | boolean | | No |
| app_id | string | | No |
| author_name | string | | No |
| bound_agent_id | string | | No |
@ -14201,6 +14206,7 @@ about. Stage 4 §4.2.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No |
| description | string | | No |
| type | [DeclaredOutputType](#declaredoutputtype) | | Yes |
@ -14229,6 +14235,7 @@ code can call ``output.failure_strategy.on_failure`` without None-guards.
| ---- | ---- | ----------- | -------- |
| array_item | [DeclaredArrayItem](#declaredarrayitem) | | No |
| check | [DeclaredOutputCheckConfig](#declaredoutputcheckconfig) | | No |
| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No |
| description | string | | No |
| failure_strategy | [DeclaredOutputFailureStrategy](#declaredoutputfailurestrategy) | | No |
| file | [DeclaredOutputFileConfig](#declaredoutputfileconfig) | | No |

View File

@ -1,6 +1,6 @@
from typing import Any, TypedDict
from sqlalchemy import func, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from libs.datetime_utils import naive_utc_now
@ -56,6 +56,7 @@ class AgentRosterService:
agent: Agent,
active_version: AgentConfigSnapshot | None = None,
published_references: list[AgentReferencingWorkflow] | None = None,
active_config_is_published: bool = False,
) -> dict[str, Any]:
published_references = published_references or []
return {
@ -74,6 +75,7 @@ class AgentRosterService:
"workflow_node_id": agent.workflow_node_id,
"active_config_snapshot_id": agent.active_config_snapshot_id,
"active_config_snapshot": AgentRosterService.serialize_version(active_version) if active_version else None,
"active_config_is_published": active_config_is_published,
"status": agent.status.value,
"created_by": agent.created_by,
"updated_by": agent.updated_by,
@ -128,6 +130,10 @@ class AgentRosterService:
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,
)
data = []
for agent in agents:
@ -139,6 +145,7 @@ class AgentRosterService:
agent,
active_version,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
)
)
@ -165,11 +172,16 @@ class AgentRosterService:
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,
)
data = [
self.serialize_agent(
agent,
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),
)
for agent in agents
]
@ -429,7 +441,16 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_ids=[agent.id],
)
return self.serialize_agent(agent, active_version, published_references_by_agent_id.get(agent.id, []))
active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=[agent],
)
return self.serialize_agent(
agent,
active_version,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
)
def update_roster_agent(
self, *, tenant_id: str, agent_id: str, account_id: str, payload: RosterAgentUpdatePayload
@ -471,6 +492,18 @@ class AgentRosterService:
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
}
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
"""Return whether the Agent's current active snapshot is a visible published version."""
return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get(
agent.id,
False,
)
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
"""Return publish-state flags for the active config snapshots of the given Agents."""
published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents)
return {agent.id: agent.id in published_agent_ids for agent in agents}
def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
visible_version_ids = (
@ -568,6 +601,29 @@ class AgentRosterService:
raise AgentVersionNotFoundError()
return version
def _load_published_active_snapshot_agent_ids(self, *, tenant_id: str, agents: list[Agent]) -> set[str]:
predicates = [
and_(
AgentConfigRevision.agent_id == agent.id,
AgentConfigRevision.current_snapshot_id == agent.active_config_snapshot_id,
AgentConfigRevision.operation.in_(self._visible_version_operations(agent)),
)
for agent in agents
if agent.active_config_snapshot_id
]
if not predicates:
return set()
agent_ids = self._session.scalars(
select(AgentConfigRevision.agent_id)
.where(
AgentConfigRevision.tenant_id == tenant_id,
or_(*predicates),
)
.distinct()
).all()
return set(agent_ids)
def _load_published_references_by_agent_id(
self, *, tenant_id: str, agent_ids: list[str]
) -> dict[str, list[AgentReferencingWorkflow]]:

View File

@ -199,12 +199,16 @@ def test_agent_app_list_and_create_use_agent_route(
monkeypatch.setattr(
roster_controller.AgentRosterService,
"load_app_backing_agents_by_app_id",
lambda _self, **kwargs: {"app-list": SimpleNamespace(id="agent-list", role="List role")},
lambda _self, **kwargs: {
"app-list": SimpleNamespace(id="agent-list", role="List role", active_config_snapshot_id=None)
},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_app_backing_agent",
lambda _self, **kwargs: SimpleNamespace(id="agent-created", role="Created role"),
lambda _self, **kwargs: SimpleNamespace(
id="agent-created", role="Created role", active_config_snapshot_id=None
),
)
monkeypatch.setattr(
roster_controller.FeatureService,
@ -221,6 +225,7 @@ def test_agent_app_list_and_create_use_agent_route(
assert listed["data"][0]["id"] == "agent-list"
assert listed["data"][0]["app_id"] == "app-list"
assert listed["data"][0]["role"] == "List role"
assert listed["data"][0]["active_config_is_published"] is False
assert "bound_agent_id" not in listed["data"][0]
list_call = cast(dict[str, object], captured["list"])
list_params = cast(Any, list_call["params"])
@ -237,6 +242,7 @@ def test_agent_app_list_and_create_use_agent_route(
assert created["id"] == "agent-created"
assert created["app_id"] == "app-created"
assert created["role"] == "Created role"
assert created["active_config_is_published"] is False
assert "bound_agent_id" not in created
create_call = cast(dict[str, object], captured["create"])
create_params = cast(Any, create_call["params"])
@ -258,7 +264,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_app_backing_agent",
lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role"),
lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role", active_config_snapshot_id=None),
)
monkeypatch.setattr(
roster_controller.FeatureService,
@ -284,6 +290,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
assert detail["id"] == agent_id
assert detail["app_id"] == "app-1"
assert detail["role"] == "Resolved role"
assert detail["active_config_is_published"] is False
assert "bound_agent_id" not in detail
with app.test_request_context(
@ -296,6 +303,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
assert updated["id"] == agent_id
assert updated["app_id"] == "app-1"
assert updated["role"] == "Resolved role"
assert updated["active_config_is_published"] is False
assert "bound_agent_id" not in updated
update_call = cast(dict[str, object], captured["update"])
assert update_call["app"] is app_model

View File

@ -21,6 +21,9 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentSoulConfig,
AgentSoulModelConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
)
@ -321,6 +324,7 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys():
"secret_refs": [
{"variable": "TOKEN", "credential_id": "credential-1"},
{"name": "API_KEY", "provider_credential_id": "credential-2"},
{"name": "EDITABLE_TOKEN", "value": "credential-3"},
{"ref": "missing-name"},
],
},
@ -341,6 +345,7 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys():
assert config["secret_refs"] == [
{"name": "TOKEN", "ref": "credential-1"},
{"name": "API_KEY", "ref": "credential-2"},
{"name": "EDITABLE_TOKEN", "ref": "credential-3"},
]
assert config["sandbox"] is None
@ -630,6 +635,40 @@ def test_array_output_emits_typed_items_per_array_item():
assert output_schema["required"] == ["tags"]
def test_nested_declared_output_emits_object_and_array_child_schema():
profile_output = DeclaredOutputConfig(
name="profile",
type=DeclaredOutputType.OBJECT,
children=[
DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING),
DeclaredOutputChildConfig(
name="nickname",
type=DeclaredOutputType.STRING,
required=False,
description="Optional display name",
),
DeclaredOutputChildConfig(
name="addresses",
type=DeclaredOutputType.ARRAY,
array_item=DeclaredArrayItem(
type=DeclaredOutputType.OBJECT,
description="Address item",
children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)],
),
),
],
)
schema = WorkflowAgentRuntimeRequestBuilder._schema_for_declared_output(profile_output)
assert schema["properties"]["email"] == {"type": "string"}
assert schema["properties"]["nickname"] == {"type": "string", "description": "Optional display name"}
assert schema["properties"]["addresses"]["items"]["properties"]["city"] == {"type": "string"}
assert schema["properties"]["addresses"]["items"]["description"] == "Address item"
assert schema["properties"]["addresses"]["items"]["required"] == ["city"]
assert schema["required"] == ["email", "addresses"]
def test_effective_declared_outputs_passthrough_when_user_declared():
"""effective_declared_outputs() must return user-provided outputs verbatim
when non-empty; only empty input gets PRD defaults injected."""

View File

@ -1,7 +1,12 @@
import pytest
from core.workflow.file_reference import build_file_reference
from models.agent_config_entities import DeclaredOutputConfig, DeclaredOutputType
from models.agent_config_entities import (
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
DeclaredOutputType,
)
def test_file_default_value_accepts_canonical_reference_mapping() -> None:
@ -92,3 +97,90 @@ def test_array_file_default_value_rejects_legacy_item_shape() -> None:
},
}
)
def test_declared_array_item_rejects_nested_arrays_and_non_object_children() -> None:
with pytest.raises(ValueError, match="nested arrays"):
DeclaredArrayItem(type=DeclaredOutputType.ARRAY)
with pytest.raises(ValueError, match="array_item.children"):
DeclaredArrayItem(
type=DeclaredOutputType.STRING,
children=[DeclaredOutputChildConfig(name="label", type=DeclaredOutputType.STRING)],
)
def test_declared_output_child_validates_shape_and_defaults() -> None:
file_child = DeclaredOutputChildConfig(name="report", type=DeclaredOutputType.FILE)
assert file_child.file is not None
array_child = DeclaredOutputChildConfig(name="items", type=DeclaredOutputType.ARRAY)
assert array_child.array_item is not None
assert array_child.array_item.type == DeclaredOutputType.OBJECT
with pytest.raises(ValueError, match="output child name"):
DeclaredOutputChildConfig(name="bad-name", type=DeclaredOutputType.STRING)
with pytest.raises(ValueError, match="file metadata"):
DeclaredOutputChildConfig(name="title", type=DeclaredOutputType.STRING, file={})
with pytest.raises(ValueError, match="array_item is only allowed"):
DeclaredOutputChildConfig(
name="title",
type=DeclaredOutputType.STRING,
array_item={"type": DeclaredOutputType.STRING},
)
with pytest.raises(ValueError, match="children is only allowed"):
DeclaredOutputChildConfig(
name="title",
type=DeclaredOutputType.STRING,
children=[DeclaredOutputChildConfig(name="label", type=DeclaredOutputType.STRING)],
)
def test_declared_output_validates_shape_and_defaults() -> None:
file_output = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)
assert file_output.file is not None
array_output = DeclaredOutputConfig(name="items", type=DeclaredOutputType.ARRAY)
assert array_output.array_item is not None
assert array_output.array_item.type == DeclaredOutputType.OBJECT
default_failure_strategy = DeclaredOutputConfig.model_validate(
{"name": "summary", "type": "string", "failure_strategy": None}
)
assert default_failure_strategy.failure_strategy.on_failure == "stop"
with pytest.raises(ValueError, match="output name"):
DeclaredOutputConfig(name="bad-name", type=DeclaredOutputType.STRING)
with pytest.raises(ValueError, match="file metadata"):
DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, file={})
with pytest.raises(ValueError, match="array_item is only allowed"):
DeclaredOutputConfig(
name="summary",
type=DeclaredOutputType.STRING,
array_item={"type": DeclaredOutputType.STRING},
)
with pytest.raises(ValueError, match="children is only allowed"):
DeclaredOutputConfig(
name="summary",
type=DeclaredOutputType.STRING,
children=[DeclaredOutputChildConfig(name="title", type=DeclaredOutputType.STRING)],
)
with pytest.raises(ValueError, match="output check is only allowed"):
DeclaredOutputConfig.model_validate(
{
"name": "summary",
"type": "string",
"check": {
"enabled": True,
"prompt": "Compare output",
"benchmark_file_ref": {"name": "expected.pdf"},
},
}
)

View File

@ -17,6 +17,8 @@ from models.agent import (
)
from models.agent_config_entities import (
AgentFileRefConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
@ -112,7 +114,7 @@ def test_load_workflow_composer_returns_empty_state(monkeypatch):
effective = result["effective_declared_outputs"]
assert [o["name"] for o in effective] == ["text", "files", "json"]
files_output = next(o for o in effective if o["name"] == "files")
assert files_output["array_item"] == {"type": "file", "description": None}
assert files_output["array_item"] == {"type": "file", "description": None, "children": []}
def test_load_workflow_composer_serializes_existing_binding(monkeypatch):
@ -649,6 +651,7 @@ def test_roster_list_and_invite_options(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_published_active_snapshot_agent_ids", lambda **kwargs: {"agent-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")
@ -661,6 +664,8 @@ def test_roster_list_and_invite_options(monkeypatch):
assert listed["data"][0]["created_at"] == int(created_at.timestamp())
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"][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"]
@ -690,6 +695,7 @@ def test_invite_options_uses_db_filtered_pagination(monkeypatch):
},
)
monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {})
monkeypatch.setattr(service, "_load_published_active_snapshot_agent_ids", lambda **kwargs: set())
result = service.list_invite_options(tenant_id="tenant-1", page=1, limit=1)
@ -698,6 +704,41 @@ def test_invite_options_uses_db_filtered_pagination(monkeypatch):
assert [item["id"] for item in result["data"]] == ["agent-2"]
def test_active_config_is_published_flags_handle_matching_and_empty_snapshots():
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
name="Published",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="version-1",
)
draft_agent = Agent(
id="agent-2",
tenant_id="tenant-1",
name="Draft",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id=None,
)
service = AgentRosterService(FakeSession(scalars=[["agent-1"], ["agent-1"]]))
flags = service.load_active_config_is_published_by_agent_id(tenant_id="tenant-1", agents=[agent, draft_agent])
assert flags == {"agent-1": True, "agent-2": False}
assert service.active_config_is_published(tenant_id="tenant-1", agent=agent) is True
assert AgentRosterService(FakeSession()).load_active_config_is_published_by_agent_id(
tenant_id="tenant-1",
agents=[draft_agent],
) == {"agent-2": False}
def test_published_references_include_app_display_fields_and_sort_by_updated_at():
recent_updated_at = datetime(2026, 1, 7, 3, 4, 5, tzinfo=UTC)
stale_updated_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC)
@ -1042,7 +1083,7 @@ def test_composer_validator_accepts_valid_shell_env_and_cli():
{
"env": {
"variables": [{"name": "MY_VAR", "value": "v"}],
"secret_refs": [{"name": "API_TOKEN", "id": "credential-1"}],
"secret_refs": [{"name": "API_TOKEN", "value": "credential-1"}],
},
"tools": {
"cli_tools": [
@ -1051,7 +1092,7 @@ def test_composer_validator_accepts_valid_shell_env_and_cli():
"command": "apt-get install -y jq",
"env": {
"variables": [{"name": "JQ_COLOR", "value": "1"}],
"secret_refs": [{"name": "JQ_TOKEN", "id": "credential-2"}],
"secret_refs": [{"name": "JQ_TOKEN", "value": "credential-2"}],
},
},
{
@ -1067,8 +1108,10 @@ def test_composer_validator_accepts_valid_shell_env_and_cli():
)
assert {variable.name for variable in config.env.variables} == {"MY_VAR"}
assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"}
assert config.env.secret_refs[0].value == "credential-1"
assert config.tools.cli_tools[0].env.variables[0].name == "JQ_COLOR"
assert config.tools.cli_tools[0].env.secret_refs[0].name == "JQ_TOKEN"
assert config.tools.cli_tools[0].env.secret_refs[0].value == "credential-2"
class TestAgentAppBackingAgent:
@ -1263,7 +1306,22 @@ class TestWorkflowAgentDraftBindingSync:
node_job_config=WorkflowNodeJobConfig(
workflow_prompt="Summarize the upstream result.",
declared_outputs=[
DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary")
DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary"),
DeclaredOutputConfig(
name="profile",
type=DeclaredOutputType.OBJECT,
children=[
DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING),
DeclaredOutputChildConfig(
name="addresses",
type=DeclaredOutputType.ARRAY,
array_item=DeclaredArrayItem(
type=DeclaredOutputType.OBJECT,
children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)],
),
),
],
),
],
),
)
@ -1279,6 +1337,9 @@ class TestWorkflowAgentDraftBindingSync:
assert node_data["agent_declared_outputs"][0]["name"] == "summary"
assert node_data["agent_declared_outputs"][0]["type"] == "string"
assert node_data["agent_declared_outputs"][0]["description"] == "Short summary"
profile_output = node_data["agent_declared_outputs"][1]
assert profile_output["children"][0]["name"] == "email"
assert profile_output["children"][1]["array_item"]["children"][0]["name"] == "city"
assert "agent_declared_outputs" not in workflow.graph_dict["nodes"][0]["data"]
def test_creates_roster_binding_from_agent_node_graph(self):

View File

@ -23,6 +23,7 @@ export type AgentAppCreatePayload = {
export type AppDetailWithSite = {
access_mode?: string | null
active_config_is_published?: boolean
api_base_url?: string | null
app_id?: string | null
bound_agent_id?: string | null
@ -259,6 +260,7 @@ export type AgentConfigSnapshotDetailResponse = {
export type AppPartial = {
access_mode?: string | null
active_config_is_published?: boolean
app_id?: string | null
author_name?: string | null
bound_agent_id?: string | null
@ -345,6 +347,7 @@ export type WorkflowPartial = {
}
export type AgentInviteOptionResponse = {
active_config_is_published?: boolean
active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null
active_config_snapshot_id?: string | null
agent_kind: AgentKind
@ -757,6 +760,26 @@ export type AgentSoulToolsConfig = {
export type DeclaredOutputConfig = {
array_item?: DeclaredArrayItem | null
check?: DeclaredOutputCheckConfig | null
children?: Array<{
array_item?: {
children?: Array<{
[key: string]: unknown
}>
description?: string | null
type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
[key: string]: unknown
}
children?: Array<{
[key: string]: unknown
}>
description?: string | null
file?: {
[key: string]: unknown
}
name: string
required?: boolean
type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
}>
description?: string | null
failure_strategy?: DeclaredOutputFailureStrategy
file?: DeclaredOutputFileConfig | null
@ -949,6 +972,7 @@ export type AgentSecretRefConfig = {
provider_credential_id?: string | null
ref?: string | null
type?: string | null
value?: string | null
variable?: string | null
[key: string]: unknown
}
@ -1073,6 +1097,26 @@ export type AgentSoulDifyToolConfig = {
}
export type DeclaredArrayItem = {
children?: Array<{
array_item?: {
children?: Array<{
[key: string]: unknown
}>
description?: string | null
type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
[key: string]: unknown
}
children?: Array<{
[key: string]: unknown
}>
description?: string | null
file?: {
[key: string]: unknown
}
name: string
required?: boolean
type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
}>
description?: string | null
type: DeclaredOutputType
}
@ -1224,6 +1268,7 @@ export type AppPaginationWritable = {
export type AppDetailWithSiteWritable = {
access_mode?: string | null
active_config_is_published?: boolean
api_base_url?: string | null
app_id?: string | null
bound_agent_id?: string | null
@ -1253,6 +1298,7 @@ export type AppDetailWithSiteWritable = {
export type AppPartialWritable = {
access_mode?: string | null
active_config_is_published?: boolean
app_id?: string | null
author_name?: string | null
bound_agent_id?: string | null

View File

@ -475,6 +475,7 @@ export const zModelConfigPartial = z.object({
*/
export const zAppPartial = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
app_id: z.string().nullish(),
author_name: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@ -534,6 +535,7 @@ export const zModelConfig = z.object({
*/
export const zAppDetailWithSite = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
api_base_url: z.string().nullish(),
app_id: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@ -620,6 +622,7 @@ export const zAgentStatus = z.enum(['active', 'archived'])
* AgentInviteOptionResponse
*/
export const zAgentInviteOptionResponse = z.object({
active_config_is_published: z.boolean().optional().default(false),
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(),
active_config_snapshot_id: z.string().nullish(),
agent_kind: zAgentKind,
@ -1078,6 +1081,25 @@ export const zWorkflowNodeJobMetadata = z.object({
* about. Stage 4 §4.2.
*/
export const zDeclaredArrayItem = z.object({
children: z
.array(
z.object({
array_item: z
.object({
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(),
})
.optional(),
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
file: z.record(z.string(), z.unknown()).optional(),
name: z.string(),
required: z.boolean().optional(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']),
}),
)
.optional(),
description: z.string().nullish(),
type: zDeclaredOutputType,
})
@ -1136,6 +1158,7 @@ export const zAgentSecretRefConfig = z.object({
provider_credential_id: z.string().max(255).nullish(),
ref: z.string().max(255).nullish(),
type: z.string().max(64).nullish(),
value: z.string().max(255).nullish(),
variable: z.string().max(255).nullish(),
})
@ -1515,6 +1538,25 @@ export const zDeclaredOutputFailureStrategy = z.object({
export const zDeclaredOutputConfig = z.object({
array_item: zDeclaredArrayItem.nullish(),
check: zDeclaredOutputCheckConfig.nullish(),
children: z
.array(
z.object({
array_item: z
.object({
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(),
})
.optional(),
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
file: z.record(z.string(), z.unknown()).optional(),
name: z.string(),
required: z.boolean().optional(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']),
}),
)
.optional(),
description: z.string().nullish(),
failure_strategy: zDeclaredOutputFailureStrategy.optional(),
file: zDeclaredOutputFileConfig.nullish(),
@ -1735,6 +1777,7 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({
*/
export const zAppPartialWritable = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
app_id: z.string().nullish(),
author_name: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@ -1795,6 +1838,7 @@ export const zSiteWritable = z.object({
*/
export const zAppDetailWithSiteWritable = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
api_base_url: z.string().nullish(),
app_id: z.string().nullish(),
bound_agent_id: z.string().nullish(),

View File

@ -23,6 +23,7 @@ export type CreateAppPayload = {
export type AppDetailWithSite = {
access_mode?: string | null
active_config_is_published?: boolean
api_base_url?: string | null
app_id?: string | null
bound_agent_id?: string | null
@ -1155,6 +1156,7 @@ export type ApiKeyItem = {
export type AppPartial = {
access_mode?: string | null
active_config_is_published?: boolean
app_id?: string | null
author_name?: string | null
bound_agent_id?: string | null
@ -1760,6 +1762,26 @@ export type AgentComposerBindingResponse = {
export type DeclaredOutputConfig = {
array_item?: DeclaredArrayItem | null
check?: DeclaredOutputCheckConfig | null
children?: Array<{
array_item?: {
children?: Array<{
[key: string]: unknown
}>
description?: string | null
type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
[key: string]: unknown
}
children?: Array<{
[key: string]: unknown
}>
description?: string | null
file?: {
[key: string]: unknown
}
name: string
required?: boolean
type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
}>
description?: string | null
failure_strategy?: DeclaredOutputFailureStrategy
file?: DeclaredOutputFileConfig | null
@ -2103,6 +2125,26 @@ export type AgentSoulToolsConfig = {
export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent'
export type DeclaredArrayItem = {
children?: Array<{
array_item?: {
children?: Array<{
[key: string]: unknown
}>
description?: string | null
type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
[key: string]: unknown
}
children?: Array<{
[key: string]: unknown
}>
description?: string | null
file?: {
[key: string]: unknown
}
name: string
required?: boolean
type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
}>
description?: string | null
type: DeclaredOutputType
}
@ -2305,6 +2347,7 @@ export type AgentSecretRefConfig = {
provider_credential_id?: string | null
ref?: string | null
type?: string | null
value?: string | null
variable?: string | null
[key: string]: unknown
}
@ -2543,6 +2586,7 @@ export type AppPaginationWritable = {
export type AppDetailWithSiteWritable = {
access_mode?: string | null
active_config_is_published?: boolean
api_base_url?: string | null
app_id?: string | null
bound_agent_id?: string | null
@ -2595,6 +2639,7 @@ export type WorkflowCommentDetailWritable = {
export type AppPartialWritable = {
access_mode?: string | null
active_config_is_published?: boolean
app_id?: string | null
author_name?: string | null
bound_agent_id?: string | null

View File

@ -1946,6 +1946,7 @@ export const zModelConfigPartial = z.object({
*/
export const zAppPartial = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
app_id: z.string().nullish(),
author_name: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@ -2005,6 +2006,7 @@ export const zModelConfig = z.object({
*/
export const zAppDetailWithSite = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
api_base_url: z.string().nullish(),
app_id: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@ -2475,6 +2477,25 @@ export const zAgentComposerBindingResponse = z.object({
* about. Stage 4 §4.2.
*/
export const zDeclaredArrayItem = z.object({
children: z
.array(
z.object({
array_item: z
.object({
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(),
})
.optional(),
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
file: z.record(z.string(), z.unknown()).optional(),
name: z.string(),
required: z.boolean().optional(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']),
}),
)
.optional(),
description: z.string().nullish(),
type: zDeclaredOutputType,
})
@ -2908,6 +2929,7 @@ export const zAgentSecretRefConfig = z.object({
provider_credential_id: z.string().max(255).nullish(),
ref: z.string().max(255).nullish(),
type: z.string().max(64).nullish(),
value: z.string().max(255).nullish(),
variable: z.string().max(255).nullish(),
})
@ -3079,6 +3101,25 @@ export const zDeclaredOutputCheckConfig = z.object({
export const zDeclaredOutputConfig = z.object({
array_item: zDeclaredArrayItem.nullish(),
check: zDeclaredOutputCheckConfig.nullish(),
children: z
.array(
z.object({
array_item: z
.object({
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(),
})
.optional(),
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
file: z.record(z.string(), z.unknown()).optional(),
name: z.string(),
required: z.boolean().optional(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']),
}),
)
.optional(),
description: z.string().nullish(),
failure_strategy: zDeclaredOutputFailureStrategy.optional(),
file: zDeclaredOutputFileConfig.nullish(),
@ -3437,6 +3478,7 @@ export const zGeneratedAppResponseWritable = zJsonValue
*/
export const zAppPartialWritable = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
app_id: z.string().nullish(),
author_name: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@ -3497,6 +3539,7 @@ export const zSiteWritable = z.object({
*/
export const zAppDetailWithSiteWritable = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
api_base_url: z.string().nullish(),
app_id: z.string().nullish(),
bound_agent_id: z.string().nullish(),