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'