mirror of
https://github.com/langgenius/dify.git
synced 2026-06-22 19:21:13 +08:00
fix(agent): resolve roster file downloads versions and log filters (#37626)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
aa777a1f7a
commit
0df30dd269
@ -114,11 +114,21 @@ class AgentLogsQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, description="Page number")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Page size")
|
||||
keyword: str | None = Field(default=None, description="Search query, answer, or conversation name")
|
||||
status: str | None = Field(default=None, description="Filter by success, failed, or paused")
|
||||
status: str | None = Field(default=None, description="Deprecated single status filter")
|
||||
statuses: list[str] = Field(default_factory=list, description="Filter by one or more of success, failed, paused")
|
||||
source: str | None = Field(
|
||||
default=None,
|
||||
description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger",
|
||||
description="Deprecated single source filter",
|
||||
)
|
||||
sources: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Filter by one or more source IDs, e.g. webapp:<app_id> "
|
||||
"or workflow:<app_id>:<workflow_id>:<version>:<node_id>"
|
||||
),
|
||||
)
|
||||
sort_by: str = Field(default="updated_at", description="Sort by created_at or updated_at")
|
||||
sort_order: str = Field(default="desc", description="Sort order: asc or desc")
|
||||
start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)")
|
||||
end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)")
|
||||
|
||||
@ -129,6 +139,33 @@ class AgentLogsQuery(BaseModel):
|
||||
return None
|
||||
return value
|
||||
|
||||
@field_validator("statuses", "sources", mode="before")
|
||||
@classmethod
|
||||
def empty_list_values_to_list(cls, value: object) -> list[str]:
|
||||
if value in (None, ""):
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if item]
|
||||
return []
|
||||
|
||||
@field_validator("sort_by")
|
||||
@classmethod
|
||||
def validate_sort_by(cls, value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"created_at", "updated_at"}:
|
||||
raise ValueError("sort_by must be created_at or updated_at")
|
||||
return normalized
|
||||
|
||||
@field_validator("sort_order")
|
||||
@classmethod
|
||||
def validate_sort_order(cls, value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"asc", "desc"}:
|
||||
raise ValueError("sort_order must be asc or desc")
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentStatisticsQuery(BaseModel):
|
||||
source: str | None = Field(
|
||||
@ -298,6 +335,18 @@ def _parse_observability_time_range(start: str | None, end: str | None, account:
|
||||
abort(400, description=str(exc))
|
||||
|
||||
|
||||
def _multi_query_values(name: str, legacy_name: str | None = None) -> list[str]:
|
||||
values: list[str] = []
|
||||
for query_name in (name, f"{name}[]"):
|
||||
values.extend(request.args.getlist(query_name))
|
||||
if legacy_name:
|
||||
values.extend(request.args.getlist(legacy_name))
|
||||
parsed: list[str] = []
|
||||
for value in values:
|
||||
parsed.extend(item.strip() for item in value.split(",") if item.strip())
|
||||
return parsed
|
||||
|
||||
|
||||
@console_ns.route("/agent")
|
||||
class AgentAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AppListQuery))
|
||||
@ -463,7 +512,10 @@ class AgentLogsApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
|
||||
query_data["sources"] = _multi_query_values("sources", "source")
|
||||
query_data["statuses"] = _multi_query_values("statuses", "status")
|
||||
query = AgentLogsQuery.model_validate(query_data)
|
||||
start, end = _parse_observability_time_range(query.start, query.end, current_user)
|
||||
try:
|
||||
payload = _agent_observability_service().list_logs(
|
||||
@ -473,8 +525,10 @@ class AgentLogsApi(Resource):
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
source=query.source,
|
||||
statuses=tuple(query.statuses),
|
||||
sources=tuple(query.sources),
|
||||
sort_by=query.sort_by,
|
||||
sort_order=query.sort_order,
|
||||
start=start,
|
||||
end=end,
|
||||
),
|
||||
@ -495,7 +549,10 @@ class AgentLogMessagesApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
|
||||
query_data["sources"] = _multi_query_values("sources", "source")
|
||||
query_data["statuses"] = _multi_query_values("statuses", "status")
|
||||
query = AgentLogsQuery.model_validate(query_data)
|
||||
start, end = _parse_observability_time_range(query.start, query.end, current_user)
|
||||
try:
|
||||
payload = _agent_observability_service().list_log_messages(
|
||||
@ -506,8 +563,10 @@ class AgentLogMessagesApi(Resource):
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
source=query.source,
|
||||
statuses=tuple(query.statuses),
|
||||
sources=tuple(query.sources),
|
||||
sort_by=query.sort_by,
|
||||
sort_order=query.sort_order,
|
||||
start=start,
|
||||
end=end,
|
||||
),
|
||||
|
||||
@ -36,7 +36,13 @@ from services.entities.agent_entities import (
|
||||
class AgentConfigSnapshotSummaryResponse(ResponseModel):
|
||||
id: str
|
||||
agent_id: str | None = None
|
||||
# User-facing version number among visible published versions.
|
||||
version: int
|
||||
# Alias for the user-facing version number; kept explicit for clients that
|
||||
# want to distinguish it from the immutable snapshot sequence.
|
||||
display_version: int | None = None
|
||||
# Immutable snapshot sequence number used internally for audit/history.
|
||||
snapshot_version: int | None = None
|
||||
summary: str | None = None
|
||||
version_note: str | None = None
|
||||
created_by: str | None = None
|
||||
|
||||
@ -680,9 +680,13 @@ Commit an uploaded file into the Agent App drive under files/<name>
|
||||
| keyword | query | Search query, answer, or conversation name | No | string |
|
||||
| limit | query | Page size | No | integer, <br>**Default:** 20 |
|
||||
| page | query | Page number | No | integer, <br>**Default:** 1 |
|
||||
| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string |
|
||||
| sort_by | query | Sort by created_at or updated_at | No | string, <br>**Default:** updated_at |
|
||||
| sort_order | query | Sort order: asc or desc | No | string, <br>**Default:** desc |
|
||||
| source | query | Deprecated single source filter | No | string |
|
||||
| sources | query | Filter by one or more source IDs, e.g. webapp:<app_id> or workflow:<app_id>:<workflow_id>:<version>:<node_id> | No | [ string ] |
|
||||
| start | query | Start date (YYYY-MM-DD HH:MM) | No | string |
|
||||
| status | query | Filter by success, failed, or paused | No | string |
|
||||
| status | query | Deprecated single status filter | No | string |
|
||||
| statuses | query | Filter by one or more of success, failed, paused | No | [ string ] |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Responses
|
||||
@ -700,9 +704,13 @@ Commit an uploaded file into the Agent App drive under files/<name>
|
||||
| keyword | query | Search query, answer, or conversation name | No | string |
|
||||
| limit | query | Page size | No | integer, <br>**Default:** 20 |
|
||||
| page | query | Page number | No | integer, <br>**Default:** 1 |
|
||||
| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string |
|
||||
| sort_by | query | Sort by created_at or updated_at | No | string, <br>**Default:** updated_at |
|
||||
| sort_order | query | Sort order: asc or desc | No | string, <br>**Default:** desc |
|
||||
| source | query | Deprecated single source filter | No | string |
|
||||
| sources | query | Filter by one or more source IDs, e.g. webapp:<app_id> or workflow:<app_id>:<workflow_id>:<version>:<node_id> | No | [ string ] |
|
||||
| start | query | Start date (YYYY-MM-DD HH:MM) | No | string |
|
||||
| status | query | Filter by success, failed, or paused | No | string |
|
||||
| status | query | Deprecated single status filter | No | string |
|
||||
| statuses | query | Filter by one or more of success, failed, paused | No | [ string ] |
|
||||
| agent_id | path | | Yes | string (uuid) |
|
||||
| conversation_id | path | | Yes | string (uuid) |
|
||||
|
||||
@ -11702,8 +11710,10 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
| config_snapshot | [AgentSoulConfig](#agentsoulconfig) | | Yes |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| display_version | integer | | No |
|
||||
| id | string | | Yes |
|
||||
| revisions | [ [AgentConfigRevisionResponse](#agentconfigrevisionresponse) ] | | No |
|
||||
| snapshot_version | integer | | No |
|
||||
| summary | string | | No |
|
||||
| version | integer | | Yes |
|
||||
| version_note | string | | No |
|
||||
@ -11721,7 +11731,9 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
| agent_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| display_version | integer | | No |
|
||||
| id | string | | Yes |
|
||||
| snapshot_version | integer | | No |
|
||||
| summary | string | | No |
|
||||
| version | integer | | Yes |
|
||||
| version_note | string | | No |
|
||||
@ -12110,9 +12122,13 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| keyword | string | Search query, answer, or conversation name | No |
|
||||
| limit | integer, <br>**Default:** 20 | Page size | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number | No |
|
||||
| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No |
|
||||
| sort_by | string, <br>**Default:** updated_at | Sort by created_at or updated_at | No |
|
||||
| sort_order | string, <br>**Default:** desc | Sort order: asc or desc | No |
|
||||
| source | string | Deprecated single source filter | No |
|
||||
| sources | [ string ] | Filter by one or more source IDs, e.g. webapp:<app_id> or workflow:<app_id>:<workflow_id>:<version>:<node_id> | No |
|
||||
| start | string | Start date (YYYY-MM-DD HH:MM) | No |
|
||||
| status | string | Filter by success, failed, or paused | No |
|
||||
| status | string | Deprecated single status filter | No |
|
||||
| statuses | [ string ] | Filter by one or more of success, failed, paused | No |
|
||||
|
||||
#### AgentMemoryArtifactConfig
|
||||
|
||||
|
||||
@ -22,8 +22,10 @@ class AgentLogQueryParams:
|
||||
page: int = 1
|
||||
limit: int = 20
|
||||
keyword: str | None = None
|
||||
status: str | None = None
|
||||
source: str | None = None
|
||||
statuses: tuple[str, ...] = ()
|
||||
sources: tuple[str, ...] = ()
|
||||
sort_by: str = "updated_at"
|
||||
sort_order: str = "desc"
|
||||
start: datetime | None = None
|
||||
end: datetime | None = None
|
||||
|
||||
@ -104,6 +106,18 @@ class AgentObservabilityService:
|
||||
)
|
||||
return AgentSourceFilter(kind="webapp", invoke_from=cls.resolve_source(source))
|
||||
|
||||
@classmethod
|
||||
def resolve_source_filters(cls, sources: tuple[str, ...]) -> list[AgentSourceFilter]:
|
||||
if not sources:
|
||||
return [AgentSourceFilter(kind="all")]
|
||||
filters: list[AgentSourceFilter] = []
|
||||
for source in sources:
|
||||
source_filter = cls.resolve_source_filter(source)
|
||||
if source_filter.kind == "all":
|
||||
return [source_filter]
|
||||
filters.append(source_filter)
|
||||
return filters
|
||||
|
||||
@staticmethod
|
||||
def _message_status(message: Message) -> str:
|
||||
if message.error or message.status == MessageStatus.ERROR:
|
||||
@ -143,20 +157,24 @@ class AgentObservabilityService:
|
||||
}
|
||||
|
||||
def list_logs(self, *, app: App, agent_id: str, params: AgentLogQueryParams) -> dict[str, Any]:
|
||||
source_filter = self.resolve_source_filter(params.source)
|
||||
source_filters = self.resolve_source_filters(params.sources)
|
||||
rows: list[dict[str, Any]] = []
|
||||
if source_filter.kind in {"all", "webapp"}:
|
||||
rows.extend(self._list_webapp_conversation_logs(app=app, params=params, source_filter=source_filter))
|
||||
if source_filter.kind in {"all", "workflow"}:
|
||||
rows.extend(
|
||||
self._list_workflow_conversation_logs(
|
||||
app=app,
|
||||
agent_id=agent_id,
|
||||
params=params,
|
||||
source_filter=source_filter,
|
||||
for source_filter in source_filters:
|
||||
if source_filter.kind in {"all", "webapp"}:
|
||||
rows.extend(self._list_webapp_conversation_logs(app=app, params=params, source_filter=source_filter))
|
||||
if source_filter.kind in {"all", "workflow"}:
|
||||
rows.extend(
|
||||
self._list_workflow_conversation_logs(
|
||||
app=app,
|
||||
agent_id=agent_id,
|
||||
params=params,
|
||||
source_filter=source_filter,
|
||||
)
|
||||
)
|
||||
)
|
||||
rows.sort(key=lambda row: (row["updated_at"] or 0, row["id"]), reverse=True)
|
||||
rows_by_scope = {(row["id"], row["source"]["id"] if row.get("source") else ""): row for row in rows}
|
||||
rows = list(rows_by_scope.values())
|
||||
sort_by = "created_at" if params.sort_by == "created_at" else "updated_at"
|
||||
rows.sort(key=lambda row: (row[sort_by] or 0, row["id"]), reverse=params.sort_order != "asc")
|
||||
|
||||
total = len(rows)
|
||||
start = (params.page - 1) * params.limit
|
||||
@ -172,30 +190,36 @@ class AgentObservabilityService:
|
||||
def list_log_messages(
|
||||
self, *, app: App, agent_id: str, conversation_id: str, params: AgentLogQueryParams
|
||||
) -> dict[str, Any]:
|
||||
source_filter = self.resolve_source_filter(params.source)
|
||||
source_filters = self.resolve_source_filters(params.sources)
|
||||
rows: list[Message] = []
|
||||
if source_filter.kind in {"all", "webapp"}:
|
||||
rows.extend(
|
||||
self._list_webapp_messages(
|
||||
app=app,
|
||||
conversation_id=conversation_id,
|
||||
params=params,
|
||||
source_filter=source_filter,
|
||||
for source_filter in source_filters:
|
||||
if source_filter.kind in {"all", "webapp"}:
|
||||
rows.extend(
|
||||
self._list_webapp_messages(
|
||||
app=app,
|
||||
conversation_id=conversation_id,
|
||||
params=params,
|
||||
source_filter=source_filter,
|
||||
)
|
||||
)
|
||||
)
|
||||
if source_filter.kind in {"all", "workflow"}:
|
||||
rows.extend(
|
||||
self._list_workflow_messages(
|
||||
app=app,
|
||||
agent_id=agent_id,
|
||||
conversation_id=conversation_id,
|
||||
params=params,
|
||||
source_filter=source_filter,
|
||||
if source_filter.kind in {"all", "workflow"}:
|
||||
rows.extend(
|
||||
self._list_workflow_messages(
|
||||
app=app,
|
||||
agent_id=agent_id,
|
||||
conversation_id=conversation_id,
|
||||
params=params,
|
||||
source_filter=source_filter,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
deduped = {message.id: message for message in rows}
|
||||
sorted_rows = sorted(deduped.values(), key=lambda message: (message.created_at, message.id), reverse=True)
|
||||
sort_column = Message.created_at if params.sort_by == "created_at" else Message.updated_at
|
||||
sorted_rows = sorted(
|
||||
deduped.values(),
|
||||
key=lambda message: (getattr(message, sort_column.key), message.id),
|
||||
reverse=params.sort_order != "asc",
|
||||
)
|
||||
total = len(sorted_rows)
|
||||
start = (params.page - 1) * params.limit
|
||||
end = start + params.limit
|
||||
@ -421,8 +445,8 @@ class AgentObservabilityService:
|
||||
Message.answer.ilike(pattern, escape="\\"),
|
||||
)
|
||||
)
|
||||
if params.status:
|
||||
stmt = cls._apply_status_filter(stmt, params.status)
|
||||
if params.statuses:
|
||||
stmt = cls._apply_status_filter(stmt, params.statuses)
|
||||
return stmt
|
||||
|
||||
@staticmethod
|
||||
@ -444,15 +468,21 @@ class AgentObservabilityService:
|
||||
return stmt.where(Message.invoke_from == source)
|
||||
|
||||
@staticmethod
|
||||
def _apply_status_filter(stmt, status: str):
|
||||
normalized = status.strip().lower()
|
||||
if normalized in {"success", "normal"}:
|
||||
return stmt.where(Message.error.is_(None), Message.status == MessageStatus.NORMAL)
|
||||
if normalized in {"failed", "error"}:
|
||||
return stmt.where(or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR))
|
||||
if normalized == "paused":
|
||||
return stmt.where(Message.status == MessageStatus.PAUSED)
|
||||
raise ValueError(f"Unsupported status: {status}")
|
||||
def _apply_status_filter(stmt, statuses: tuple[str, ...]):
|
||||
conditions = []
|
||||
for status in statuses:
|
||||
normalized = status.strip().lower()
|
||||
if normalized in {"success", "normal"}:
|
||||
conditions.append(and_(Message.error.is_(None), Message.status == MessageStatus.NORMAL))
|
||||
elif normalized in {"failed", "error"}:
|
||||
conditions.append(or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR))
|
||||
elif normalized == "paused":
|
||||
conditions.append(Message.status == MessageStatus.PAUSED)
|
||||
else:
|
||||
raise ValueError(f"Unsupported status: {status}")
|
||||
if not conditions:
|
||||
return stmt
|
||||
return stmt.where(or_(*conditions))
|
||||
|
||||
@classmethod
|
||||
def _serialize_conversation_log(
|
||||
|
||||
@ -121,12 +121,27 @@ class AgentRosterService:
|
||||
"id": version.id,
|
||||
"agent_id": version.agent_id,
|
||||
"version": version.version,
|
||||
"display_version": version.version,
|
||||
"snapshot_version": version.version,
|
||||
"summary": version.summary,
|
||||
"version_note": version.version_note,
|
||||
"created_by": version.created_by,
|
||||
"created_at": to_timestamp(version.created_at),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_visible_version(
|
||||
cls,
|
||||
version: AgentConfigSnapshot,
|
||||
*,
|
||||
display_version: int,
|
||||
) -> dict[str, Any]:
|
||||
payload = cls.serialize_version(version) or {}
|
||||
payload["version"] = display_version
|
||||
payload["display_version"] = display_version
|
||||
payload["snapshot_version"] = version.version
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _build_roster_agents_stmt(*, tenant_id: str, keyword: str | None = None):
|
||||
stmt = select(Agent).where(
|
||||
@ -673,15 +688,7 @@ class AgentRosterService:
|
||||
|
||||
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 = (
|
||||
select(AgentConfigRevision.current_snapshot_id)
|
||||
.where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
AgentConfigRevision.operation.in_(self._visible_version_operations(agent)),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
visible_version_ids = self._visible_version_ids_stmt(tenant_id=tenant_id, agent_id=agent_id, agent=agent)
|
||||
versions = list(
|
||||
self._session.scalars(
|
||||
select(AgentConfigSnapshot)
|
||||
@ -693,25 +700,39 @@ class AgentRosterService:
|
||||
.order_by(AgentConfigSnapshot.version.desc())
|
||||
).all()
|
||||
)
|
||||
total = len(versions)
|
||||
return [
|
||||
serialized_version
|
||||
for version in versions
|
||||
if (serialized_version := self.serialize_version(version)) is not None
|
||||
self._serialize_visible_version(version, display_version=total - index)
|
||||
for index, version in enumerate(versions)
|
||||
]
|
||||
|
||||
def get_agent_version_detail(self, *, tenant_id: str, agent_id: str, version_id: str) -> dict[str, Any]:
|
||||
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
|
||||
visible_revision_id = self._session.scalar(
|
||||
select(AgentConfigRevision.id)
|
||||
def _visible_version_ids_stmt(self, *, tenant_id: str, agent_id: str, agent: Agent):
|
||||
return (
|
||||
select(AgentConfigRevision.current_snapshot_id)
|
||||
.where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
AgentConfigRevision.current_snapshot_id == version_id,
|
||||
AgentConfigRevision.operation.in_(self._visible_version_operations(agent)),
|
||||
)
|
||||
.limit(1)
|
||||
.subquery()
|
||||
)
|
||||
if not visible_revision_id:
|
||||
|
||||
def get_agent_version_detail(self, *, tenant_id: str, agent_id: str, version_id: str) -> dict[str, Any]:
|
||||
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
|
||||
visible_version_ids = self._visible_version_ids_stmt(tenant_id=tenant_id, agent_id=agent_id, agent=agent)
|
||||
visible_versions = list(
|
||||
self._session.scalars(
|
||||
select(AgentConfigSnapshot)
|
||||
.where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
AgentConfigSnapshot.id.in_(select(visible_version_ids.c.current_snapshot_id)),
|
||||
)
|
||||
.order_by(AgentConfigSnapshot.version.asc())
|
||||
).all()
|
||||
)
|
||||
display_versions_by_id = {version.id: index for index, version in enumerate(visible_versions, start=1)}
|
||||
if version_id not in display_versions_by_id:
|
||||
raise AgentVersionNotFoundError()
|
||||
version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
|
||||
revisions = list(
|
||||
@ -725,7 +746,7 @@ class AgentRosterService:
|
||||
.order_by(AgentConfigRevision.revision.desc())
|
||||
).all()
|
||||
)
|
||||
result = self.serialize_version(version) or {}
|
||||
result = self._serialize_visible_version(version, display_version=display_versions_by_id[version_id])
|
||||
result["config_snapshot"] = version.config_snapshot_dict
|
||||
result["revisions"] = [
|
||||
{
|
||||
|
||||
@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
@ -386,7 +387,12 @@ class AgentDriveService:
|
||||
|
||||
@staticmethod
|
||||
def _resolve_download_url(
|
||||
*, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str, for_external: bool = False
|
||||
*,
|
||||
tenant_id: str,
|
||||
file_kind: AgentDriveFileKind,
|
||||
file_id: str,
|
||||
for_external: bool = False,
|
||||
as_attachment: bool = False,
|
||||
) -> str | None:
|
||||
"""Signed URL for a drive value. ``for_external`` selects the audience:
|
||||
the inner manifest hands agents *internal* URLs, while the console
|
||||
@ -398,10 +404,22 @@ class AgentDriveService:
|
||||
controller = DatabaseFileAccessController()
|
||||
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
|
||||
try:
|
||||
if file_kind == AgentDriveFileKind.UPLOAD_FILE:
|
||||
return runtime.resolve_upload_file_url(
|
||||
upload_file_id=file_id,
|
||||
for_external=for_external,
|
||||
as_attachment=as_attachment,
|
||||
)
|
||||
# No FileAccessScope bound -> drive-owned: the builders still filter by
|
||||
# tenant_id, so resolution is tenant-scoped without user-level checks.
|
||||
file = file_factory.build_from_mapping(mapping=mapping, tenant_id=tenant_id, access_controller=controller)
|
||||
return runtime.resolve_file_url(file=file, for_external=for_external)
|
||||
url = runtime.resolve_file_url(file=file, for_external=for_external)
|
||||
if as_attachment and url:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
||||
query.append(("as_attachment", "true"))
|
||||
return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query)))
|
||||
return url
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@ -475,7 +493,11 @@ class AgentDriveService:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
url = self._resolve_download_url(
|
||||
tenant_id=tenant_id, file_kind=row.file_kind, file_id=row.file_id, for_external=True
|
||||
tenant_id=tenant_id,
|
||||
file_kind=row.file_kind,
|
||||
file_id=row.file_id,
|
||||
for_external=True,
|
||||
as_attachment=True,
|
||||
)
|
||||
if url is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
|
||||
|
||||
@ -658,7 +658,8 @@ def test_agent_observability_routes_resolve_app_from_agent_id(
|
||||
account = SimpleNamespace(id=account_id, timezone="UTC")
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/logs"
|
||||
"?page=2&limit=5&keyword=hello&status=success&source=console"
|
||||
"?page=2&limit=5&keyword=hello&statuses[]=success&statuses[]=failed&sources[]=webapp:app-1"
|
||||
"&sources[]=workflow:app-2:workflow-1:v1:node-1&sort_by=created_at&sort_order=asc"
|
||||
):
|
||||
logs = unwrap(AgentLogsApi.get)(AgentLogsApi(), "tenant-1", account, agent_id)
|
||||
|
||||
@ -671,8 +672,10 @@ def test_agent_observability_routes_resolve_app_from_agent_id(
|
||||
assert logs_params.page == 2
|
||||
assert logs_params.limit == 5
|
||||
assert logs_params.keyword == "hello"
|
||||
assert logs_params.status == "success"
|
||||
assert logs_params.source == "console"
|
||||
assert logs_params.statuses == ("success", "failed")
|
||||
assert logs_params.sources == ("webapp:app-1", "workflow:app-2:workflow-1:v1:node-1")
|
||||
assert logs_params.sort_by == "created_at"
|
||||
assert logs_params.sort_order == "asc"
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/logs/00000000-0000-0000-0000-000000000002/messages"
|
||||
@ -690,6 +693,9 @@ def test_agent_observability_routes_resolve_app_from_agent_id(
|
||||
assert messages_call["app"] is app_model
|
||||
assert messages_call["agent_id"] == agent_id
|
||||
assert messages_call["conversation_id"] == "00000000-0000-0000-0000-000000000002"
|
||||
messages_params = cast(Any, messages_call["params"])
|
||||
assert messages_params.sources == ()
|
||||
assert messages_params.statuses == ()
|
||||
|
||||
with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/log-sources"):
|
||||
sources = unwrap(AgentLogSourcesApi.get)(AgentLogSourcesApi(), "tenant-1", account, agent_id)
|
||||
|
||||
@ -6,7 +6,7 @@ import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.enums import ConversationFromSource, MessageStatus
|
||||
from services.agent.observability_service import AgentObservabilityService
|
||||
from services.agent.observability_service import AgentLogQueryParams, AgentObservabilityService
|
||||
|
||||
|
||||
def test_resolve_source_accepts_frontend_aliases() -> None:
|
||||
@ -40,6 +40,54 @@ def test_resolve_source_filter_accepts_structured_sources() -> None:
|
||||
AgentObservabilityService.resolve_source_filter("workflow:broken")
|
||||
|
||||
|
||||
def test_resolve_source_filters_accepts_multiple_structured_sources() -> None:
|
||||
filters = AgentObservabilityService.resolve_source_filters(("webapp:app-1", "workflow:app-2:workflow-1:v1:node-1"))
|
||||
|
||||
assert [source_filter.kind for source_filter in filters] == ["webapp", "workflow"]
|
||||
assert filters[0].app_id == "app-1"
|
||||
assert filters[1].node_id == "node-1"
|
||||
assert AgentObservabilityService.resolve_source_filters(())[0].kind == "all"
|
||||
assert AgentObservabilityService.resolve_source_filters(("all", "webapp:app-1"))[0].kind == "all"
|
||||
|
||||
|
||||
def test_apply_status_filter_accepts_multiple_statuses() -> None:
|
||||
class FakeStmt:
|
||||
def __init__(self):
|
||||
self.conditions = []
|
||||
|
||||
def where(self, *conditions):
|
||||
self.conditions.extend(conditions)
|
||||
return self
|
||||
|
||||
stmt = FakeStmt()
|
||||
|
||||
result = AgentObservabilityService._apply_status_filter(stmt, ("success", "failed", "paused"))
|
||||
|
||||
assert result is stmt
|
||||
assert len(stmt.conditions) == 1
|
||||
with pytest.raises(ValueError, match="Unsupported status"):
|
||||
AgentObservabilityService._apply_status_filter(FakeStmt(), ("unknown",))
|
||||
|
||||
|
||||
def test_list_logs_sorts_by_requested_field(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = AgentObservabilityService(session=None)
|
||||
app = SimpleNamespace(id="app-1")
|
||||
rows = [
|
||||
{"id": "old", "source": {"id": "webapp:app-1"}, "created_at": 10, "updated_at": 100},
|
||||
{"id": "new", "source": {"id": "webapp:app-1"}, "created_at": 20, "updated_at": 50},
|
||||
]
|
||||
monkeypatch.setattr(service, "_list_webapp_conversation_logs", lambda **kwargs: rows)
|
||||
monkeypatch.setattr(service, "_list_workflow_conversation_logs", lambda **kwargs: [])
|
||||
|
||||
payload = service.list_logs(
|
||||
app=app, # type: ignore[arg-type]
|
||||
agent_id="agent-1",
|
||||
params=AgentLogQueryParams(sources=("webapp:app-1",), sort_by="created_at", sort_order="asc"),
|
||||
)
|
||||
|
||||
assert [item["id"] for item in payload["data"]] == ["old", "new"]
|
||||
|
||||
|
||||
def test_source_serializers_return_structured_frontend_shape() -> None:
|
||||
app = SimpleNamespace(
|
||||
id="app-1",
|
||||
|
||||
@ -916,14 +916,16 @@ def test_published_references_include_app_display_fields_and_sort_by_updated_at(
|
||||
|
||||
|
||||
def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPatch):
|
||||
listed_version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2)
|
||||
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)
|
||||
listed_version.created_at = listed_version_created_at
|
||||
older_listed_version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2)
|
||||
older_listed_version.created_at = datetime(2026, 1, 4, 3, 4, 5, tzinfo=UTC)
|
||||
revision_created_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC)
|
||||
revision = SimpleNamespace(
|
||||
id="revision-1",
|
||||
previous_snapshot_id=None,
|
||||
current_snapshot_id="version-1",
|
||||
current_snapshot_id="version-2",
|
||||
revision=1,
|
||||
operation=AgentConfigRevisionOperation.CREATE_VERSION,
|
||||
summary=None,
|
||||
@ -931,7 +933,10 @@ def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPat
|
||||
created_by="account-1",
|
||||
created_at=revision_created_at,
|
||||
)
|
||||
fake_session = FakeSession(scalar=["visible-revision"], scalars=[[listed_version], [revision]])
|
||||
fake_session = FakeSession(
|
||||
scalar=["visible-revision"],
|
||||
scalars=[[listed_version, older_listed_version], [older_listed_version, listed_version], [revision]],
|
||||
)
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
@ -942,7 +947,7 @@ def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPat
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
)
|
||||
version = AgentConfigSnapshot(id="version-1", agent_id="agent-1", version=1, config_snapshot='{"prompt":{}}')
|
||||
version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2, config_snapshot='{"prompt":{}}')
|
||||
version.created_at = datetime(2026, 1, 4, 3, 4, 5, tzinfo=UTC)
|
||||
|
||||
service = AgentRosterService(fake_session)
|
||||
@ -962,12 +967,21 @@ def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPat
|
||||
)
|
||||
service.archive_roster_agent(tenant_id="tenant-1", agent_id="agent-1", account_id="account-1")
|
||||
versions = service.list_agent_versions(tenant_id="tenant-1", agent_id="agent-1")
|
||||
detail = service.get_agent_version_detail(tenant_id="tenant-1", agent_id="agent-1", version_id="version-1")
|
||||
detail = service.get_agent_version_detail(tenant_id="tenant-1", agent_id="agent-1", version_id="version-2")
|
||||
|
||||
assert updated["description"] == "new"
|
||||
assert agent.status == AgentStatus.ARCHIVED
|
||||
assert versions[0]["id"] == "version-2"
|
||||
assert versions[0]["id"] == "version-4"
|
||||
assert versions[0]["version"] == 2
|
||||
assert versions[0]["display_version"] == 2
|
||||
assert versions[0]["snapshot_version"] == 4
|
||||
assert versions[1]["id"] == "version-2"
|
||||
assert versions[1]["version"] == 1
|
||||
assert versions[1]["snapshot_version"] == 2
|
||||
assert versions[0]["created_at"] == int(listed_version_created_at.timestamp())
|
||||
assert detail["version"] == 1
|
||||
assert detail["display_version"] == 1
|
||||
assert detail["snapshot_version"] == 2
|
||||
assert detail["config_snapshot"] == {"prompt": {}}
|
||||
assert detail["created_at"] == int(version.created_at.timestamp())
|
||||
assert detail["revisions"][0]["created_at"] == int(revision_created_at.timestamp())
|
||||
|
||||
@ -66,6 +66,7 @@ def _tables() -> Generator[None, None, None]:
|
||||
yield
|
||||
with session_factory.create_session() as session:
|
||||
session.execute(delete(AgentDriveFile))
|
||||
session.execute(delete(UploadFile))
|
||||
session.execute(delete(ToolFile))
|
||||
session.execute(delete(Agent))
|
||||
session.commit()
|
||||
@ -493,6 +494,20 @@ def test_download_url_signs_external_audience():
|
||||
assert url == "https://signed.example/x"
|
||||
# console downloads are for browsers: external signing, never the internal URL
|
||||
assert resolver.call_args.kwargs["for_external"] is True
|
||||
assert resolver.call_args.kwargs["as_attachment"] is True
|
||||
|
||||
|
||||
def test_upload_file_download_url_uses_attachment_filename():
|
||||
upload_file_id = _seed_upload_file(name="report.pdf")
|
||||
_commit_upload("files/report.pdf", upload_file_id)
|
||||
|
||||
with patch("services.agent_drive_service.DifyWorkflowFileRuntime") as runtime_cls:
|
||||
runtime_cls.return_value.resolve_upload_file_url.return_value = "https://files.example/report.pdf"
|
||||
url = AgentDriveService().download_url(tenant_id=TENANT, agent_id=AGENT, key="files/report.pdf")
|
||||
|
||||
assert url == "https://files.example/report.pdf"
|
||||
assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["for_external"] is True
|
||||
assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["as_attachment"] is True
|
||||
|
||||
|
||||
def test_manifest_items_carry_created_at_for_inspector():
|
||||
|
||||
@ -281,8 +281,10 @@ export type AgentConfigSnapshotDetailResponse = {
|
||||
config_snapshot: AgentSoulConfig
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
display_version?: number | null
|
||||
id: string
|
||||
revisions?: Array<AgentConfigRevisionResponse>
|
||||
snapshot_version?: number | null
|
||||
summary?: string | null
|
||||
version: number
|
||||
version_note?: string | null
|
||||
@ -414,7 +416,9 @@ export type AgentConfigSnapshotSummaryResponse = {
|
||||
agent_id?: string | null
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
display_version?: number | null
|
||||
id: string
|
||||
snapshot_version?: number | null
|
||||
summary?: string | null
|
||||
version: number
|
||||
version_note?: string | null
|
||||
@ -1926,9 +1930,13 @@ export type GetAgentByAgentIdLogsData = {
|
||||
keyword?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
source?: string
|
||||
sources?: Array<string>
|
||||
start?: string
|
||||
status?: string
|
||||
statuses?: Array<string>
|
||||
}
|
||||
url: '/agent/{agent_id}/logs'
|
||||
}
|
||||
@ -1951,9 +1959,13 @@ export type GetAgentByAgentIdLogsByConversationIdMessagesData = {
|
||||
keyword?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
source?: string
|
||||
sources?: Array<string>
|
||||
start?: string
|
||||
status?: string
|
||||
statuses?: Array<string>
|
||||
}
|
||||
url: '/agent/{agent_id}/logs/{conversation_id}/messages'
|
||||
}
|
||||
|
||||
@ -187,7 +187,9 @@ export const zAgentConfigSnapshotSummaryResponse = z.object({
|
||||
agent_id: z.string().nullish(),
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
display_version: z.int().nullish(),
|
||||
id: z.string(),
|
||||
snapshot_version: z.int().nullish(),
|
||||
summary: z.string().nullish(),
|
||||
version: z.int(),
|
||||
version_note: z.string().nullish(),
|
||||
@ -1702,8 +1704,10 @@ export const zAgentConfigSnapshotDetailResponse = z.object({
|
||||
config_snapshot: zAgentSoulConfig,
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
display_version: z.int().nullish(),
|
||||
id: z.string(),
|
||||
revisions: z.array(zAgentConfigRevisionResponse).optional(),
|
||||
snapshot_version: z.int().nullish(),
|
||||
summary: z.string().nullish(),
|
||||
version: z.int(),
|
||||
version_note: z.string().nullish(),
|
||||
@ -2356,9 +2360,13 @@ export const zGetAgentByAgentIdLogsQuery = z.object({
|
||||
keyword: z.string().optional(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
sort_by: z.string().optional().default('updated_at'),
|
||||
sort_order: z.string().optional().default('desc'),
|
||||
source: z.string().optional(),
|
||||
sources: z.array(z.string()).optional(),
|
||||
start: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
statuses: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -2376,9 +2384,13 @@ export const zGetAgentByAgentIdLogsByConversationIdMessagesQuery = z.object({
|
||||
keyword: z.string().optional(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
sort_by: z.string().optional().default('updated_at'),
|
||||
sort_order: z.string().optional().default('desc'),
|
||||
source: z.string().optional(),
|
||||
sources: z.array(z.string()).optional(),
|
||||
start: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
statuses: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@ -1710,7 +1710,9 @@ export type AgentConfigSnapshotSummaryResponse = {
|
||||
agent_id?: string | null
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
display_version?: number | null
|
||||
id: string
|
||||
snapshot_version?: number | null
|
||||
summary?: string | null
|
||||
version: number
|
||||
version_note?: string | null
|
||||
|
||||
@ -1736,7 +1736,9 @@ export const zAgentConfigSnapshotSummaryResponse = z.object({
|
||||
agent_id: z.string().nullish(),
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
display_version: z.int().nullish(),
|
||||
id: z.string(),
|
||||
snapshot_version: z.int().nullish(),
|
||||
summary: z.string().nullish(),
|
||||
version: z.int(),
|
||||
version_note: z.string().nullish(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user