diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py
index 3d213ab1fffbbb..9e4b9e9e9ca823 100644
--- a/api/controllers/console/app/agent.py
+++ b/api/controllers/console/app/agent.py
@@ -1,26 +1,23 @@
from typing import Any
+from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
-from sqlalchemy.orm import Session
from controllers.common.rbac import PlainApp, RBACCheck
from controllers.common.schema import query_params_from_model, register_response_schema_models
-from controllers.common.session import with_session
from controllers.console import console_ns
-from controllers.console.app.wraps import get_app_model
+from controllers.console.app.error import AppNotFoundError
+from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
- account_initialization_required,
model_validate,
- rbac_permission_required,
- setup_required,
)
+from extensions.ext_application_services import application_services
from fields.base import ResponseModel
-from libs.helper import uuid_value
-from libs.login import login_required
-from models.model import App, AppMode
-from services.agent_service import AgentService
+from libs.helper import dump_response, uuid_value
+from machinery.context import RequestContext
+from services.agent.log_contracts import AgentLogAppNotFoundError
class AgentLogQuery(BaseModel):
@@ -49,8 +46,8 @@ class AgentToolCallResponse(ResponseModel):
time_cost: float | int
tool_name: str
tool_label: str | dict[str, str] = Field(description="Tool name or its i18n labels keyed by locale")
- tool_input: dict[str, Any]
- tool_output: dict[str, Any]
+ tool_input: Any
+ tool_output: Any
tool_parameters: dict[str, Any]
tool_icon: str | dict[str, str] | None = Field(default=None, description="Icon URL or emoji icon")
@@ -81,14 +78,18 @@ class AgentLogApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentLogQuery))
@console_ns.response(200, "Agent logs retrieved successfully", console_ns.models[AgentLogResponse.__name__])
@console_ns.response(400, "Invalid request parameters")
- @setup_required
- @login_required
- @account_initialization_required
- @rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
- @with_session(write=False)
- @get_app_model(mode=[AppMode.AGENT_CHAT])
+ @console_account_admission(rbac_checks=[RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())])
@model_validate(AgentLogQuery)
- def get(self, req_data: AgentLogQuery, session: Session, app_model: App):
+ def get(self, req_data: AgentLogQuery, request_context: RequestContext, app_id: UUID):
"""Get agent logs."""
- return AgentService.get_agent_logs(app_model, req_data.conversation_id, req_data.message_id, session)
+ try:
+ result = application_services().agent_apps.logs.get(
+ request_context,
+ app_id=str(app_id),
+ conversation_id=req_data.conversation_id,
+ message_id=req_data.message_id,
+ )
+ except AgentLogAppNotFoundError as error:
+ raise AppNotFoundError() from error
+ return dump_response(AgentLogResponse, result)
diff --git a/api/extensions/application_services/agent.py b/api/extensions/application_services/agent.py
index d7aa6b0f9b8f22..d88ddff4b3a8fc 100644
--- a/api/extensions/application_services/agent.py
+++ b/api/extensions/application_services/agent.py
@@ -1,21 +1,29 @@
"""Composition of the Agent App console use cases."""
from dataclasses import dataclass
+from functools import partial
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
+from core.app.file_access import DatabaseFileAccessController
from repositories.app.agent_app_repository import AgentAppRepository
from repositories.app.agent_sandbox_repository import AgentSandboxRepository
+from repositories.app.generation_repository import AppGenerationRepository
+from repositories.tools.provider_repository import ToolProviderRepository
+from services.agent.log_file_gateway import AgentLogFileGateway
+from services.agent.log_service import AgentLogService
from services.app.agent_app_feature_gateway import AgentAppFeatureValidator
from services.app.agent_app_sandbox_service import AgentAppSandboxService
from services.app.agent_app_service import AgentAppAccessService, AgentAppFeatureConfigService
from services.app.agent_sandbox_file_gateway import AgentSandboxFileGateway, create_sandbox_client
from services.file_request_service import FileRequestService
+from services.tools.tool_manager import ToolManager
@dataclass(frozen=True, slots=True)
class AgentAppServices:
+ logs: AgentLogService
access: AgentAppAccessService
features: AgentAppFeatureConfigService
sandbox: AgentAppSandboxService
@@ -24,6 +32,11 @@ class AgentAppServices:
def build_agent_app_services(*, database_client: sessionmaker[Session]) -> AgentAppServices:
apps = AgentAppRepository(session_factory=database_client)
return AgentAppServices(
+ logs=AgentLogService(
+ records=AppGenerationRepository(database_client),
+ icons=partial(ToolManager.get_tool_icon, tool_providers=ToolProviderRepository(database_client)),
+ files=AgentLogFileGateway(sessions=database_client, access=DatabaseFileAccessController()),
+ ),
access=AgentAppAccessService(references=apps),
features=AgentAppFeatureConfigService(features=apps, validator=AgentAppFeatureValidator()),
sandbox=AgentAppSandboxService(
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index 046929b65e3daa..c76ad5f0d8295c 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -2534,7 +2534,6 @@ Get agent execution logs for an application
| ---- | ----------- | ------ |
| 200 | Agent logs retrieved successfully | **application/json**: [AgentLogResponse](#agentlogresponse)
|
| 400 | Invalid request parameters | **application/json**: [ErrorResponse](#errorresponse)
|
-| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
|
| default | Error | **application/json**: [ErrorResponse](#errorresponse)
|
### [POST] /apps/{app_id}/annotation-reply/{action}
@@ -21101,10 +21100,10 @@ Legacy Chat App model config used only for follow-up question generation.
| status | string | | Yes |
| time_cost | number
integer | | Yes |
| tool_icon | string
object | Icon URL or emoji icon | No |
-| tool_input | object | | Yes |
+| tool_input | | | Yes |
| tool_label | string
object | Tool name or its i18n labels keyed by locale | Yes |
| tool_name | string | | Yes |
-| tool_output | object | | Yes |
+| tool_output | | | Yes |
| tool_parameters | object | | Yes |
#### AgentUserSatisfactionRateStatisticResponse
diff --git a/api/services/agent/log_file_gateway.py b/api/services/agent/log_file_gateway.py
new file mode 100644
index 00000000000000..399c7ce527aef2
--- /dev/null
+++ b/api/services/agent/log_file_gateway.py
@@ -0,0 +1,33 @@
+"""Rebuild message attachments with the shared file factory and explicit sessions."""
+
+from typing import Any
+
+from sqlalchemy.orm import Session, sessionmaker
+
+from core.app.file_access import FileAccessControllerProtocol
+from factories.file_factory import build_from_mapping
+from graphon.file import FileTransferMethod
+
+
+class AgentLogFileGateway:
+ def __init__(self, *, sessions: sessionmaker[Session], access: FileAccessControllerProtocol) -> None:
+ self._sessions = sessions
+ self._access = access
+
+ def resolve(self, *, tenant_id: str, files: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ result = []
+ for source in files:
+ mapping = dict(source)
+ if mapping["transfer_method"] == FileTransferMethod.TOOL_FILE:
+ reference = mapping["upload_file_id"]
+ if reference is None:
+ reference = mapping["url"].split("/")[-1].split(".")[0]
+ mapping["tool_file_id"] = reference
+ mapping["upload_file_id"] = reference
+ file = build_from_mapping(
+ mapping=mapping, tenant_id=tenant_id, access_controller=self._access, sessions=self._sessions
+ )
+ result.append(
+ {"belongs_to": source["belongs_to"], "upload_file_id": mapping["upload_file_id"], **file.to_dict()}
+ )
+ return result
diff --git a/api/services/agent/log_service.py b/api/services/agent/log_service.py
new file mode 100644
index 00000000000000..a3dcf663fd477c
--- /dev/null
+++ b/api/services/agent/log_service.py
@@ -0,0 +1,96 @@
+"""Present one Agent message log after its read transaction has closed."""
+
+from datetime import UTC
+from typing import Any
+
+import pytz
+
+from core.app.app_config.easy_ui_based_app.agent.manager import AgentConfigManager
+from core.tools.entities.tool_entities import EmojiIconDict
+from libs.datetime_utils import ensure_naive_utc
+from machinery.context import RequestContext
+from services.agent.log_contracts import AgentLogConfigurationError, AgentLogFiles, AgentLogIcons, AgentLogRecords
+
+
+class AgentLogService:
+ def __init__(self, *, records: AgentLogRecords, icons: AgentLogIcons, files: AgentLogFiles) -> None:
+ self._records = records
+ self._icons = icons
+ self._files = files
+
+ def get(self, context: RequestContext, *, app_id: str, conversation_id: str, message_id: str) -> dict[str, Any]:
+ log = self._records.agent_log(
+ tenant_id=context.active_workspace_id,
+ account_id=context.account_id,
+ app_id=app_id,
+ conversation_id=conversation_id,
+ message_id=message_id,
+ )
+ agent_config = AgentConfigManager.convert(log.model_config)
+ if agent_config is None:
+ raise AgentLogConfigurationError("Agent config not found")
+ configured_tools = {tool.tool_name: tool for tool in agent_config.tools or []}
+ iterations = []
+ for thought in log.thoughts:
+ tool_calls = []
+ for name in thought.tools:
+ metadata = thought.metadata.get(name, {})
+ config = metadata.get("tool_config", {})
+ provider_type = config.get("tool_provider_type", "")
+ provider_id = config.get("tool_provider", "")
+ configured = configured_tools.get(name)
+ if not provider_type and configured is not None:
+ provider_type = configured.provider_type
+ provider_id = provider_id or configured.provider_id
+ icon: str | EmojiIconDict = ""
+ if provider_type and provider_type != "dataset-retrieval":
+ icon = self._icons(
+ tenant_id=context.active_workspace_id,
+ provider_type=provider_type,
+ provider_id=provider_id,
+ )
+ if not icon and configured is not None:
+ icon = self._icons(
+ tenant_id=context.active_workspace_id,
+ provider_type=configured.provider_type,
+ provider_id=configured.provider_id,
+ )
+ tool_calls.append(
+ {
+ "status": "success" if not metadata.get("error") else "error",
+ "error": metadata.get("error"),
+ "time_cost": metadata.get("time_cost", 0),
+ "tool_name": name,
+ "tool_label": thought.labels.get(name, name),
+ "tool_input": thought.inputs.get(name, {}),
+ "tool_output": thought.outputs.get(name, {}),
+ "tool_parameters": metadata.get("tool_parameters", {}),
+ "tool_icon": icon,
+ }
+ )
+ iterations.append(
+ {
+ "tokens": thought.tokens or 0,
+ "tool_calls": tool_calls,
+ "tool_raw": {"inputs": thought.raw_input, "outputs": thought.raw_output},
+ "thought": thought.thought,
+ "created_at": thought.created_at.isoformat(),
+ "files": thought.files,
+ }
+ )
+ return {
+ "meta": {
+ "status": "success",
+ "executor": log.executor,
+ "start_time": ensure_naive_utc(log.created_at)
+ .replace(tzinfo=UTC)
+ .astimezone(pytz.timezone(log.timezone))
+ .isoformat(),
+ "elapsed_time": log.elapsed_time,
+ "total_tokens": log.total_tokens,
+ "agent_mode": log.model_config["agent_mode"].get("strategy", "react"),
+ "iterations": len(iterations),
+ },
+ "iterations": iterations,
+ "files": self._files.resolve(tenant_id=context.active_workspace_id, files=log.files),
+ }
diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_logs.py b/api/tests/unit_tests/controllers/console/app/test_agent_logs.py
new file mode 100644
index 00000000000000..9445e1c3274866
--- /dev/null
+++ b/api/tests/unit_tests/controllers/console/app/test_agent_logs.py
@@ -0,0 +1,138 @@
+"""Agent log transport delegates stable identity without acquiring a Session."""
+
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from inspect import unwrap
+from uuid import UUID
+
+import pytest
+
+from controllers.console.app import agent
+from controllers.console.app.error import AppNotFoundError
+from controllers.console.wraps import RBACPermission
+from machinery.context import RequestContext
+from services.agent.log_contracts import AgentLogAppNotFoundError
+from tests.helpers.unit.controllers.rbac_introspection import rbac_checks
+
+CONTEXT = RequestContext("request", "trace", "viewer", "tenant")
+APP_ID = UUID("00000000-0000-0000-0000-000000000001")
+MESSAGE_ID = "00000000-0000-0000-0000-000000000002"
+CONVERSATION_ID = "00000000-0000-0000-0000-000000000003"
+
+type LogCall = tuple[RequestContext, dict[str, str]]
+
+
+def response_mapping(value: object) -> dict[str, object]:
+ assert isinstance(value, Mapping)
+ return {key: item for key, item in value.items() if isinstance(key, str)}
+
+
+@dataclass
+class LogService:
+ error: Exception | None = None
+ tool_label: str | dict[str, str] = "Search"
+ calls: list[LogCall] = field(default_factory=list)
+
+ def get(
+ self,
+ context: RequestContext,
+ *,
+ app_id: str,
+ conversation_id: str,
+ message_id: str,
+ ) -> dict[str, object]:
+ self.calls.append(
+ (
+ context,
+ {"app_id": app_id, "conversation_id": conversation_id, "message_id": message_id},
+ )
+ )
+ if self.error:
+ raise self.error
+ return {
+ "meta": {
+ "status": "success",
+ "executor": "user",
+ "start_time": "2024-01-01T00:00:00+00:00",
+ "total_tokens": 3,
+ "agent_mode": "react",
+ "iterations": 1,
+ },
+ "iterations": [
+ {
+ "tokens": 3,
+ "created_at": "2024-01-01T00:00:00",
+ "thought": "Read",
+ "tool_raw": dict[str, object](),
+ "tool_calls": [
+ {
+ "status": "success",
+ "time_cost": 0,
+ "tool_name": "search",
+ "tool_label": self.tool_label,
+ "tool_input": dict[str, object](),
+ "tool_output": "text output",
+ "tool_parameters": dict[str, object](),
+ }
+ ],
+ }
+ ],
+ "files": list[object](),
+ }
+
+
+@dataclass
+class AgentServices:
+ logs: LogService
+
+
+@dataclass
+class Services:
+ agent_apps: AgentServices
+
+
+@pytest.mark.parametrize(
+ "tool_label",
+ [
+ "Search",
+ {"en_US": "Search", "zh_Hans": "搜索"},
+ ],
+)
+def test_log_controller_delegates_context_and_serializes(
+ monkeypatch: pytest.MonkeyPatch, tool_label: str | dict[str, str]
+) -> None:
+ logs = LogService(tool_label=tool_label)
+ monkeypatch.setattr(agent, "application_services", lambda: Services(AgentServices(logs)))
+ endpoint = agent.AgentLogApi()
+ method = unwrap(endpoint.get)
+ payload = agent.AgentLogQuery(message_id=MESSAGE_ID, conversation_id=CONVERSATION_ID)
+ response = response_mapping(method(endpoint, payload, CONTEXT, APP_ID))
+ iterations = response["iterations"]
+ assert isinstance(iterations, list)
+ tool_calls = response_mapping(iterations[0])["tool_calls"]
+ assert isinstance(tool_calls, list)
+ tool_call = response_mapping(tool_calls[0])
+ assert tool_call["tool_label"] == tool_label
+ assert tool_call["tool_output"] == "text output"
+ assert logs.calls == [
+ (CONTEXT, {"app_id": str(APP_ID), "message_id": MESSAGE_ID, "conversation_id": CONVERSATION_ID})
+ ]
+
+
+def test_log_controller_translates_missing_app(monkeypatch: pytest.MonkeyPatch) -> None:
+ logs = LogService(error=AgentLogAppNotFoundError())
+ monkeypatch.setattr(agent, "application_services", lambda: Services(AgentServices(logs)))
+ endpoint = agent.AgentLogApi()
+ method = unwrap(endpoint.get)
+ payload = agent.AgentLogQuery(message_id=MESSAGE_ID, conversation_id=CONVERSATION_ID)
+
+ with pytest.raises(AppNotFoundError):
+ method(endpoint, payload, CONTEXT, APP_ID)
+
+ assert logs.calls == [
+ (CONTEXT, {"app_id": str(APP_ID), "message_id": MESSAGE_ID, "conversation_id": CONVERSATION_ID})
+ ]
+
+
+def test_log_admission_preserves_view_layout_permission() -> None:
+ assert [check.scene for check in rbac_checks(agent.AgentLogApi.get)] == [RBACPermission.APP_VIEW_LAYOUT]
diff --git a/api/tests/unit_tests/services/agent/test_log_service.py b/api/tests/unit_tests/services/agent/test_log_service.py
new file mode 100644
index 00000000000000..e34495bc3c66ef
--- /dev/null
+++ b/api/tests/unit_tests/services/agent/test_log_service.py
@@ -0,0 +1,404 @@
+"""Agent log retrieval through production composition and an isolated database."""
+
+import json
+from collections.abc import Iterator
+from datetime import datetime
+from decimal import Decimal
+from typing import Literal, TypedDict
+from uuid import uuid4
+
+import pytest
+from flask import has_app_context
+from sqlalchemy import Table, create_engine, select
+from sqlalchemy.orm import Session, sessionmaker
+from sqlalchemy.pool import QueuePool
+
+from core.db import session_factory
+from core.tools.entities.tool_entities import ApiProviderSchemaType
+from extensions.application_services.agent import build_agent_app_services
+from graphon.file import FileTransferMethod, FileType
+from machinery.context import RequestContext
+from models.account import Account
+from models.base import TypeBase
+from models.enums import ConversationFromSource, CreatorUserRole, EndUserType, MessageFileBelongsTo
+from models.model import (
+ App,
+ AppAnnotationSetting,
+ AppMode,
+ AppModelConfig,
+ Conversation,
+ EndUser,
+ Message,
+ MessageAgentThought,
+ MessageFile,
+ UploadFile,
+)
+from models.tools import ApiToolProvider, ToolFile
+from services.agent.log_contracts import AgentLogAppNotFoundError, AgentLogConfigurationError, AgentLogNotFoundError
+from services.agent.log_service import AgentLogService
+from tests.helpers.unit.model_factories import make_account, make_app, make_conversation, make_message, make_upload_file
+
+CONTEXT = RequestContext("request", None, "viewer", "tenant")
+
+type AgentLogStore = tuple[sessionmaker[Session], AgentLogService]
+
+
+class AgentToolCall(TypedDict):
+ status: str
+ error: object | None
+ time_cost: float | int
+ tool_name: str
+ tool_label: str | dict[str, str]
+ tool_input: object
+ tool_output: object
+ tool_parameters: dict[str, object]
+ tool_icon: object
+
+
+class AgentIteration(TypedDict):
+ tokens: int
+ tool_calls: list[AgentToolCall]
+ tool_raw: dict[str, object]
+ thought: str | None
+ created_at: str
+ files: list[object]
+
+
+class AgentLogMeta(TypedDict):
+ status: str
+ executor: str
+ start_time: str
+ elapsed_time: float
+ total_tokens: int
+ agent_mode: str
+ iterations: int
+
+
+class AgentLogResult(TypedDict):
+ meta: AgentLogMeta
+ iterations: list[AgentIteration]
+ files: list[dict[str, object]]
+
+
+@pytest.fixture(autouse=True)
+def _provide_app_context() -> None:
+ """This application use case must work without Flask or current_user."""
+
+
+@pytest.fixture
+def store(monkeypatch: pytest.MonkeyPatch) -> Iterator[AgentLogStore]:
+ engine = create_engine("sqlite://", poolclass=QueuePool)
+ tables: list[Table] = []
+ for model in (
+ App,
+ Account,
+ Conversation,
+ Message,
+ AppModelConfig,
+ MessageAgentThought,
+ MessageFile,
+ UploadFile,
+ ToolFile,
+ ApiToolProvider,
+ AppAnnotationSetting,
+ EndUser,
+ ):
+ table = model.__table__
+ assert isinstance(table, Table)
+ tables.append(table)
+ TypeBase.metadata.create_all(
+ engine,
+ tables=tables,
+ )
+ sessions = sessionmaker(engine, expire_on_commit=False)
+
+ def reject_global(*_args: object, **_kwargs: object) -> None:
+ pytest.fail("Agent logs accessed a global database or user")
+
+ monkeypatch.setattr(session_factory, "create_session", reject_global)
+ from extensions.ext_database import db
+
+ monkeypatch.setattr(db, "session", reject_global)
+ monkeypatch.setattr(type(db), "engine", property(reject_global))
+ with sessions.begin() as session:
+ account = make_account(account_id="viewer", timezone="Asia/Shanghai", name="Viewer")
+ config = AppModelConfig(
+ app_id="app",
+ model=json.dumps({"provider": "openai", "name": "gpt", "mode": "chat"}),
+ agent_mode=json.dumps(
+ {
+ "enabled": True,
+ "strategy": "react",
+ "tools": [
+ {"enabled": True, "provider_type": "api", "provider_id": "provider", "tool_name": "search"}
+ ],
+ }
+ ),
+ )
+ session.add_all([account, config])
+ session.flush()
+ session.add(make_app(app_id="app", tenant_id="tenant", mode=AppMode.AGENT_CHAT, app_model_config_id=config.id))
+ session.add(
+ make_conversation(
+ conversation_id="conversation",
+ app_id="app",
+ from_account_id="viewer",
+ inputs={},
+ from_source=ConversationFromSource.CONSOLE,
+ )
+ )
+ session.add(
+ make_message(
+ message_id="message",
+ app_id="app",
+ conversation_id="conversation",
+ inputs={},
+ query="Hi",
+ message={},
+ answer="Hello",
+ message_unit_price=Decimal(0),
+ answer_unit_price=Decimal(0),
+ currency="USD",
+ from_source=ConversationFromSource.CONSOLE,
+ from_account_id="viewer",
+ message_tokens=7,
+ answer_tokens=3,
+ created_at=datetime(2024, 1, 1),
+ provider_response_latency=1.5,
+ )
+ )
+ provider = ApiToolProvider(
+ tenant_id="tenant",
+ user_id="viewer",
+ name="api",
+ description="Search",
+ schema="{}",
+ schema_type_str=ApiProviderSchemaType.OPENAPI,
+ icon='{"content":"S","background":"#000"}',
+ tools_str="[]",
+ credentials_str="{}",
+ )
+ provider.id = "provider"
+ session.add(provider)
+ session.add(
+ MessageAgentThought(
+ message_id="message",
+ position=1,
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by="viewer",
+ thought="Search first",
+ tool="search",
+ tool_labels_str='{"search":{"en_US":"Search","zh_Hans":"搜索"}}',
+ tokens=10,
+ tool_meta_str='{"search":{"tool_parameters":{"limit":3},"time_cost":1.2}}',
+ tool_input='{"query":"test"}',
+ observation='{"result":"found"}',
+ message_files='["generated-file"]',
+ )
+ )
+ service = build_agent_app_services(database_client=sessions).logs
+ try:
+ yield sessions, service
+ finally:
+ engine.dispose()
+
+
+def get_log(service: AgentLogService, context: RequestContext = CONTEXT, **kwargs: str) -> AgentLogResult:
+ result = service.get(
+ context, **{"app_id": "app", "conversation_id": "conversation", "message_id": "message", **kwargs}
+ )
+ return AgentLogResult(meta=result["meta"], iterations=result["iterations"], files=result["files"])
+
+
+def test_log_uses_loaded_timezone_configuration_and_tool_metadata(store: AgentLogStore) -> None:
+ assert not has_app_context()
+ sessions, service = store
+ result = get_log(service)
+ assert result["meta"] == {
+ "status": "success",
+ "executor": "Viewer",
+ "start_time": "2024-01-01T08:00:00+08:00",
+ "elapsed_time": 1.5,
+ "total_tokens": 10,
+ "agent_mode": "react",
+ "iterations": 1,
+ }
+ thought = result["iterations"][0]
+ assert thought["tokens"] == 10
+ assert thought["files"] == ["generated-file"]
+ assert thought["tool_calls"] == [
+ {
+ "status": "success",
+ "error": None,
+ "time_cost": 1.2,
+ "tool_name": "search",
+ "tool_label": {"en_US": "Search", "zh_Hans": "搜索"},
+ "tool_input": {"query": "test"},
+ "tool_output": {"result": "found"},
+ "tool_parameters": {"limit": 3},
+ "tool_icon": {"content": "S", "background": "#000"},
+ }
+ ]
+ assert sessions.kw["bind"].pool.checkedout() == 0
+
+
+@pytest.mark.parametrize("failure", ["workspace", "mode", "conversation", "message", "config"])
+def test_log_enforces_complete_owner_chain(
+ store: AgentLogStore,
+ failure: Literal["workspace", "mode", "conversation", "message", "config"],
+) -> None:
+ sessions, service = store
+ context = CONTEXT
+ with sessions.begin() as session:
+ if failure == "workspace":
+ context = CONTEXT._replace(active_workspace_id="foreign")
+ elif failure == "mode":
+ app = session.get(App, "app")
+ assert app is not None
+ app.mode = AppMode.WORKFLOW
+ elif failure == "conversation":
+ conversation = session.get(Conversation, "conversation")
+ assert conversation is not None
+ conversation.app_id = "another-app"
+ elif failure == "message":
+ message = session.get(Message, "message")
+ assert message is not None
+ message.app_id = "another-app"
+ else:
+ app = session.get(App, "app")
+ assert app is not None
+ app.app_model_config_id = None
+ error = (
+ AgentLogAppNotFoundError
+ if failure in {"workspace", "mode"}
+ else (AgentLogConfigurationError if failure == "config" else AgentLogNotFoundError)
+ )
+ with pytest.raises(error):
+ get_log(service, context)
+
+
+@pytest.mark.parametrize(
+ "file_type", [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL, FileTransferMethod.TOOL_FILE]
+)
+def test_log_releases_read_transaction_before_file_processing(
+ store: AgentLogStore, monkeypatch: pytest.MonkeyPatch, file_type: FileTransferMethod
+) -> None:
+ sessions, service = store
+ remote_calls: list[str] = []
+
+ def remote_info(url: str) -> tuple[str, str, int]:
+ assert sessions.kw["bind"].pool.checkedout() == 0
+ remote_calls.append(url)
+ return "text/plain", "remote.txt", 42
+
+ monkeypatch.setattr("factories.file_factory.builders.get_remote_file_info", remote_info)
+ with sessions.begin() as session:
+ reference = str(uuid4())
+ if file_type == FileTransferMethod.LOCAL_FILE:
+ session.add(make_upload_file(file_id=reference, tenant_id="tenant", name="local.txt"))
+ elif file_type == FileTransferMethod.TOOL_FILE:
+ tool_file = ToolFile(
+ user_id="viewer",
+ tenant_id="tenant",
+ conversation_id="conversation",
+ file_key="generated/result.txt",
+ mimetype="text/plain",
+ name="result.txt",
+ size=15,
+ )
+ tool_file.id = reference
+ session.add(tool_file)
+ session.add(
+ MessageFile(
+ message_id="message",
+ type=FileType.DOCUMENT,
+ transfer_method=file_type,
+ url="https://example.com/remote.txt"
+ if file_type == FileTransferMethod.REMOTE_URL
+ else f"/files/tools/{reference}.txt",
+ upload_file_id=reference if file_type == FileTransferMethod.LOCAL_FILE else None,
+ belongs_to=MessageFileBelongsTo.USER,
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by="viewer",
+ )
+ )
+ result = get_log(service)
+ assert (
+ result["files"][0]["filename"]
+ == {
+ FileTransferMethod.LOCAL_FILE: "local.txt",
+ FileTransferMethod.REMOTE_URL: "remote.txt",
+ FileTransferMethod.TOOL_FILE: "result.txt",
+ }[file_type]
+ )
+ assert result["files"][0]["belongs_to"] == "user"
+ assert len(remote_calls) == (1 if file_type == FileTransferMethod.REMOTE_URL else 0)
+ assert sessions.kw["bind"].pool.checkedout() == 0
+ with sessions() as session:
+ file = session.scalar(select(MessageFile))
+ if file_type == FileTransferMethod.TOOL_FILE:
+ assert file is not None
+ assert file.upload_file_id is None # Viewing a log does not rewrite attachment records.
+
+
+def test_log_handles_missing_executor_and_malformed_thought_metadata(store: AgentLogStore) -> None:
+ sessions, service = store
+ with sessions.begin() as session:
+ conversation = session.get(Conversation, "conversation")
+ assert conversation is not None
+ conversation.from_account_id = "deleted"
+ thought = session.scalar(select(MessageAgentThought))
+ assert thought is not None
+ thought.tool_labels_str = thought.tool_meta_str = thought.tool_input = thought.observation = "invalid-json"
+ result = get_log(service)
+ assert result["meta"]["executor"] == "Unknown"
+ tool = result["iterations"][0]["tool_calls"][0]
+ assert tool["tool_label"] == "search"
+ assert tool["tool_input"] == {}
+ assert tool["tool_output"] == "invalid-json"
+
+
+@pytest.mark.parametrize("tenant_id", ["tenant", "foreign"])
+def test_log_resolves_end_user_inside_workspace(store: AgentLogStore, tenant_id: Literal["tenant", "foreign"]) -> None:
+ sessions, service = store
+ with sessions.begin() as session:
+ user = EndUser(
+ tenant_id=tenant_id, app_id="app", name="Customer", type=EndUserType.SERVICE_API, session_id="session"
+ )
+ session.add(user)
+ session.flush()
+ conversation = session.get(Conversation, "conversation")
+ assert conversation is not None
+ conversation.from_account_id = None
+ conversation.from_end_user_id = user.id
+ assert get_log(service)["meta"]["executor"] == ("Customer" if tenant_id == "tenant" else "Unknown")
+
+
+def test_log_preserves_tool_errors_and_empty_iterations(store: AgentLogStore) -> None:
+ sessions, service = store
+ with sessions.begin() as session:
+ thought = session.scalar(select(MessageAgentThought))
+ assert thought is not None
+ thought.tool_meta_str = '{"search":{"error":"Remote unavailable","time_cost":2}}'
+ thought.tokens = None
+ result = get_log(service)
+ assert result["iterations"][0]["tokens"] == 0
+ tool = result["iterations"][0]["tool_calls"][0]
+ assert (tool["status"], tool["error"], tool["time_cost"]) == ("error", "Remote unavailable", 2)
+ with sessions.begin() as session:
+ thought = session.scalar(select(MessageAgentThought))
+ assert thought is not None
+ session.delete(thought)
+ result = get_log(service)
+ assert result["meta"]["iterations"] == 0
+ assert result["iterations"] == []
+
+
+def test_log_rejects_disabled_agent_configuration(store: AgentLogStore) -> None:
+ sessions, service = store
+ with sessions.begin() as session:
+ config = session.scalar(select(AppModelConfig))
+ assert config is not None
+ config.agent_mode = '{"enabled":false}'
+ with pytest.raises(AgentLogConfigurationError, match="Agent config not found"):
+ get_log(service)
diff --git a/packages/contracts/generated/api/console/apps/errors.gen.ts b/packages/contracts/generated/api/console/apps/errors.gen.ts
index b462c78d7d9d11..4aa9548e94fe18 100644
--- a/packages/contracts/generated/api/console/apps/errors.gen.ts
+++ b/packages/contracts/generated/api/console/apps/errors.gen.ts
@@ -709,8 +709,6 @@ export const get15Errors = {
app_not_found: { status: 404, data: zAppNotFoundError },
}
-export const get16Errors = { app_not_found: { status: 404, data: zAppNotFoundError } }
-
export const post15Errors = {
no_file_uploaded: { status: 400, data: zNoFileUploadedError },
too_many_files: { status: 400, data: zTooManyFilesError },
diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts
index a22c12cfa7f571..f22bf4f76a1e38 100644
--- a/packages/contracts/generated/api/console/apps/orpc.gen.ts
+++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts
@@ -1387,7 +1387,6 @@ export const get16 = oc
})
.input(z.object({ params: zGetAppsByAppIdAgentLogsPath, query: zGetAppsByAppIdAgentLogsQuery }))
.output(zGetAppsByAppIdAgentLogsResponse)
- .errors({ app_not_found: { status: 404, data: zAppNotFoundError } })
export const logs = {
get: get16,
diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts
index f038324f68e7e0..b175d789433091 100644
--- a/packages/contracts/generated/api/console/apps/types.gen.ts
+++ b/packages/contracts/generated/api/console/apps/types.gen.ts
@@ -3133,18 +3133,14 @@ export type AgentToolCallResponse = {
[key: string]: string
}
| null
- tool_input: {
- [key: string]: unknown
- }
+ tool_input: unknown
tool_label:
| string
| {
[key: string]: string
}
tool_name: string
- tool_output: {
- [key: string]: unknown
- }
+ tool_output: unknown
tool_parameters: {
[key: string]: unknown
}
@@ -5766,7 +5762,6 @@ export type GetAppsByAppIdAgentLogsData = {
export type GetAppsByAppIdAgentLogsErrors = {
400: ErrorResponse
- 404: AppNotFoundError | ErrorResponse
default: ErrorResponse
}
diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts
index 88a054a43e8f04..9bf502a02b1537 100644
--- a/packages/contracts/generated/api/console/apps/zod.gen.ts
+++ b/packages/contracts/generated/api/console/apps/zod.gen.ts
@@ -3370,10 +3370,10 @@ export const zAgentToolCallResponse = z.object({
status: z.string(),
time_cost: z.union([z.number(), z.int()]),
tool_icon: z.union([z.string(), z.record(z.string(), z.string())]).nullish(),
- tool_input: z.record(z.string(), z.unknown()),
+ tool_input: z.unknown(),
tool_label: z.union([z.string(), z.record(z.string(), z.string())]),
tool_name: z.string(),
- tool_output: z.record(z.string(), z.unknown()),
+ tool_output: z.unknown(),
tool_parameters: z.record(z.string(), z.unknown()),
})
diff --git a/packages/contracts/generated/api/console/errors.gen.ts b/packages/contracts/generated/api/console/errors.gen.ts
index 69d7b030395261..fd47e4bbf7cf59 100644
--- a/packages/contracts/generated/api/console/errors.gen.ts
+++ b/packages/contracts/generated/api/console/errors.gen.ts
@@ -69,7 +69,6 @@ export { get15Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/pre-pr
export { get15Errors as 'GET /snippets/{snippet_id}/workflows/draft' } from './snippets/errors.gen.ts'
export { get15Errors as 'GET /trial-apps/{app_id}/site' } from './trial-apps/errors.gen.ts'
export { get16Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/preview' } from './agent/errors.gen.ts'
-export { get16Errors as 'GET /apps/{app_id}/agent/logs' } from './apps/errors.gen.ts'
export { get16Errors as 'GET /datasets/{dataset_id}/documents/{document_id}/indexing-estimate' } from './datasets/errors.gen.ts'
export { get16Errors as 'GET /knowledge-fs/spaces/{control_space_id}/documents' } from './knowledge-fs/errors.gen.ts'
export { get16Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/processing/parameters' } from './rag/errors.gen.ts'