From 11b428a73f864713fe56b73f01714bef93cca702 Mon Sep 17 00:00:00 2001 From: Yeuoly Date: Fri, 29 Mar 2024 19:23:48 +0800 Subject: [PATCH] feat: agent log --- api/controllers/console/__init__.py | 2 +- api/controllers/console/app/agent.py | 32 +++++ api/core/tools/entities/tool_entities.py | 9 +- api/core/tools/provider/api_tool_provider.py | 10 +- .../tools/provider/builtin_tool_provider.py | 1 + api/core/tools/tool/api_tool.py | 8 +- api/core/tools/tool/builtin_tool.py | 5 + api/core/tools/tool/dataset_retriever_tool.py | 13 +- api/core/tools/tool/model_tool.py | 5 +- api/core/tools/tool/tool.py | 9 ++ api/core/tools/tool_engine.py | 24 ++-- api/models/model.py | 53 ++++++++ api/services/agent_service.py | 123 ++++++++++++++++++ 13 files changed, 275 insertions(+), 19 deletions(-) create mode 100644 api/controllers/console/app/agent.py create mode 100644 api/services/agent_service.py diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 436f5a4ca0..6cee7314e2 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -8,7 +8,7 @@ api = ExternalApi(bp) from . import admin, apikey, extension, feature, setup, version, ping # Import app controllers from .app import (advanced_prompt_template, annotation, app, audio, completion, conversation, generator, message, - model_config, site, statistic, workflow, workflow_run, workflow_app_log, workflow_statistic) + model_config, site, statistic, workflow, workflow_run, workflow_app_log, workflow_statistic, agent) # Import auth controllers from .auth import activate, data_source_oauth, login, oauth # Import billing controllers diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py new file mode 100644 index 0000000000..aee367276c --- /dev/null +++ b/api/controllers/console/app/agent.py @@ -0,0 +1,32 @@ +from flask_restful import Resource, reqparse + +from controllers.console import api +from controllers.console.app.wraps import get_app_model +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from libs.helper import uuid_value +from libs.login import login_required +from models.model import AppMode +from services.agent_service import AgentService + + +class AgentLogApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.AGENT_CHAT]) + def get(self, app_model): + """Get agent logs""" + parser = reqparse.RequestParser() + parser.add_argument('message_id', type=uuid_value, required=True, location='args') + parser.add_argument('conversation_id', type=uuid_value, required=True, location='args') + + args = parser.parse_args() + + return AgentService.get_agent_logs( + app_model, + args['conversation_id'], + args['message_id'] + ) + +api.add_resource(AgentLogApi, '/apps//agent/logs') \ No newline at end of file diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py index 1c0f476f7b..fad91baf83 100644 --- a/api/core/tools/entities/tool_entities.py +++ b/api/core/tools/entities/tool_entities.py @@ -11,6 +11,7 @@ class ToolProviderType(Enum): Enum class for tool provider """ BUILT_IN = "built-in" + DATASET_RETRIEVAL = "dataset-retrieval" APP_BASED = "app-based" API_BASED = "api-based" @@ -161,6 +162,8 @@ class ToolIdentity(BaseModel): author: str = Field(..., description="The author of the tool") name: str = Field(..., description="The name of the tool") label: I18nObject = Field(..., description="The label of the tool") + provider: str = Field(..., description="The provider of the tool") + icon: Optional[str] = None class ToolCredentialsOption(BaseModel): value: str = Field(..., description="The value of the option") @@ -334,23 +337,25 @@ class ToolInvokeMeta(BaseModel): """ time_cost: float = Field(..., description="The time cost of the tool invoke") error: Optional[str] = None + tool_config: Optional[dict] = None @classmethod def empty(cls) -> 'ToolInvokeMeta': """ Get an empty instance of ToolInvokeMeta """ - return cls(time_cost=0.0, error=None) + return cls(time_cost=0.0, error=None, tool_config={}) @classmethod def error_instance(cls, error: str) -> 'ToolInvokeMeta': """ Get an instance of ToolInvokeMeta with error """ - return cls(time_cost=0.0, error=error) + return cls(time_cost=0.0, error=error, tool_config={}) def to_dict(self) -> dict: return { 'time_cost': self.time_cost, 'error': self.error, + 'tool_config': self.tool_config, } \ No newline at end of file diff --git a/api/core/tools/provider/api_tool_provider.py b/api/core/tools/provider/api_tool_provider.py index eb839e9341..598edad201 100644 --- a/api/core/tools/provider/api_tool_provider.py +++ b/api/core/tools/provider/api_tool_provider.py @@ -16,6 +16,8 @@ from models.tools import ApiToolProvider class ApiBasedToolProviderController(ToolProviderController): + provider_id: str + @staticmethod def from_db(db_provider: ApiToolProvider, auth_type: ApiProviderAuthType) -> 'ApiBasedToolProviderController': credentials_schema = { @@ -89,9 +91,10 @@ class ApiBasedToolProviderController(ToolProviderController): 'en_US': db_provider.description, 'zh_Hans': db_provider.description }, - 'icon': db_provider.icon + 'icon': db_provider.icon, }, - 'credentials_schema': credentials_schema + 'credentials_schema': credentials_schema, + 'provider_id': db_provider.id, }) @property @@ -120,7 +123,8 @@ class ApiBasedToolProviderController(ToolProviderController): 'en_US': tool_bundle.operation_id, 'zh_Hans': tool_bundle.operation_id }, - 'icon': tool_bundle.icon if tool_bundle.icon else '' + 'icon': self.identity.icon, + 'provider': self.provider_id, }, 'description': { 'human': { diff --git a/api/core/tools/provider/builtin_tool_provider.py b/api/core/tools/provider/builtin_tool_provider.py index 62e664a8f8..f72a757bc8 100644 --- a/api/core/tools/provider/builtin_tool_provider.py +++ b/api/core/tools/provider/builtin_tool_provider.py @@ -68,6 +68,7 @@ class BuiltinToolProviderController(ToolProviderController): script_path=path.join(path.dirname(path.realpath(__file__)), 'builtin', provider, 'tools', f'{tool_name}.py'), parent_type=BuiltinTool) + tool["identity"]["provider"] = provider tools.append(assistant_tool_class(**tool)) self.tools = tools diff --git a/api/core/tools/tool/api_tool.py b/api/core/tools/tool/api_tool.py index ab46dc61da..de3cd552d4 100644 --- a/api/core/tools/tool/api_tool.py +++ b/api/core/tools/tool/api_tool.py @@ -8,7 +8,8 @@ import requests import core.helper.ssrf_proxy as ssrf_proxy from core.tools.entities.tool_bundle import ApiBasedToolBundle -from core.tools.entities.tool_entities import ToolInvokeMessage +from core.tools.entities.tool_entities import ToolInvokeMessage, ToolProviderType +from core.tools.entities.user_entities import UserToolProvider from core.tools.errors import ToolInvokeError, ToolParameterValidationError, ToolProviderCredentialValidationError from core.tools.tool.tool import Tool @@ -34,7 +35,7 @@ class ApiTool(Tool): api_bundle=self.api_bundle.copy() if self.api_bundle else None, runtime=Tool.Runtime(**meta) ) - + def validate_credentials(self, credentials: dict[str, Any], parameters: dict[str, Any], format_only: bool = False) -> str: """ validate the credentials for Api tool @@ -49,6 +50,9 @@ class ApiTool(Tool): # validate response return self.validate_and_parse_response(response) + def tool_provider_type(self) -> ToolProviderType: + return UserToolProvider.ProviderType.API + def assembling_request(self, parameters: dict[str, Any]) -> dict[str, Any]: headers = {} credentials = self.runtime.credentials or {} diff --git a/api/core/tools/tool/builtin_tool.py b/api/core/tools/tool/builtin_tool.py index 75c63cd080..68193e5f69 100644 --- a/api/core/tools/tool/builtin_tool.py +++ b/api/core/tools/tool/builtin_tool.py @@ -1,6 +1,8 @@ from core.model_runtime.entities.llm_entities import LLMResult from core.model_runtime.entities.message_entities import PromptMessage, SystemPromptMessage, UserPromptMessage +from core.tools.entities.tool_entities import ToolProviderType +from core.tools.entities.user_entities import UserToolProvider from core.tools.model.tool_model_manager import ToolModelManager from core.tools.tool.tool import Tool from core.tools.utils.web_reader_tool import get_url @@ -40,6 +42,9 @@ class BuiltinTool(Tool): prompt_messages=prompt_messages, ) + def tool_provider_type(self) -> ToolProviderType: + return UserToolProvider.ProviderType.BUILTIN + def get_max_tokens(self) -> int: """ get max tokens diff --git a/api/core/tools/tool/dataset_retriever_tool.py b/api/core/tools/tool/dataset_retriever_tool.py index 1522d3af09..421f8a0483 100644 --- a/api/core/tools/tool/dataset_retriever_tool.py +++ b/api/core/tools/tool/dataset_retriever_tool.py @@ -7,7 +7,13 @@ from core.app.entities.app_invoke_entities import InvokeFrom from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler from core.rag.retrieval.dataset_retrieval import DatasetRetrieval from core.tools.entities.common_entities import I18nObject -from core.tools.entities.tool_entities import ToolDescription, ToolIdentity, ToolInvokeMessage, ToolParameter +from core.tools.entities.tool_entities import ( + ToolDescription, + ToolIdentity, + ToolInvokeMessage, + ToolParameter, + ToolProviderType, +) from core.tools.tool.tool import Tool @@ -53,7 +59,7 @@ class DatasetRetrieverTool(Tool): for langchain_tool in langchain_tools: tool = DatasetRetrieverTool( langchain_tool=langchain_tool, - identity=ToolIdentity(author='', name=langchain_tool.name, label=I18nObject(en_US='', zh_Hans='')), + identity=ToolIdentity(provider='', author='', name=langchain_tool.name, label=I18nObject(en_US='', zh_Hans='')), parameters=[], is_team_authorization=True, description=ToolDescription( @@ -77,6 +83,9 @@ class DatasetRetrieverTool(Tool): required=True, default=''), ] + + def tool_provider_type(self) -> ToolProviderType: + return ToolProviderType.DATASET_RETRIEVAL def _invoke(self, user_id: str, tool_parameters: dict[str, Any]) -> ToolInvokeMessage | list[ToolInvokeMessage]: """ diff --git a/api/core/tools/tool/model_tool.py b/api/core/tools/tool/model_tool.py index 84e6610c75..b87e85f89c 100644 --- a/api/core/tools/tool/model_tool.py +++ b/api/core/tools/tool/model_tool.py @@ -11,7 +11,7 @@ from core.model_runtime.entities.message_entities import ( UserPromptMessage, ) from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel -from core.tools.entities.tool_entities import ModelToolPropertyKey, ToolInvokeMessage +from core.tools.entities.tool_entities import ModelToolPropertyKey, ToolInvokeMessage, ToolProviderType from core.tools.tool.tool import Tool VISION_PROMPT = """## Image Recognition Task @@ -79,6 +79,9 @@ class ModelTool(Tool): """ pass + def tool_provider_type(self) -> ToolProviderType: + return ToolProviderType.BUILT_IN + def _invoke(self, user_id: str, tool_parameters: dict[str, Any]) -> ToolInvokeMessage | list[ToolInvokeMessage]: """ """ diff --git a/api/core/tools/tool/tool.py b/api/core/tools/tool/tool.py index 8f556cfa1a..045802dd63 100644 --- a/api/core/tools/tool/tool.py +++ b/api/core/tools/tool/tool.py @@ -9,6 +9,7 @@ from core.tools.entities.tool_entities import ( ToolIdentity, ToolInvokeMessage, ToolParameter, + ToolProviderType, ToolRuntimeImageVariable, ToolRuntimeVariable, ToolRuntimeVariablePool, @@ -59,6 +60,14 @@ class Tool(BaseModel, ABC): runtime=Tool.Runtime(**meta), ) + @abstractmethod + def tool_provider_type(self) -> ToolProviderType: + """ + get the tool provider type + + :return: the tool provider type + """ + def load_variables(self, variables: ToolRuntimeVariablePool): """ load variables from database diff --git a/api/core/tools/tool_engine.py b/api/core/tools/tool_engine.py index 33bfafb423..65e7765adc 100644 --- a/api/core/tools/tool_engine.py +++ b/api/core/tools/tool_engine.py @@ -1,3 +1,4 @@ +from copy import deepcopy from datetime import datetime, timezone from typing import Union @@ -55,11 +56,7 @@ class ToolEngine: tool_inputs=tool_parameters ) - try: - meta, response = ToolEngine._invoke(tool, tool_parameters, user_id) - except ToolEngineInvokeError as e: - meta = e.meta - + meta, response = ToolEngine._invoke(tool, tool_parameters, user_id) response = ToolFileMessageTransformer.transform_tool_invoke_messages( messages=response, user_id=user_id, @@ -104,11 +101,16 @@ class ToolEngine: except ToolInvokeError as e: error_response = f"tool invoke error: {e}" agent_tool_callback.on_tool_error(e) + except ToolEngineInvokeError as e: + meta = e.args[0] + error_response = f"tool invoke error: {meta.error}" + agent_tool_callback.on_tool_error(e) + return error_response, [], meta except Exception as e: error_response = f"unknown error: {e}" agent_tool_callback.on_tool_error(e) - return error_response, [], meta + return error_response, [], ToolInvokeMeta.error_instance(error_response) @staticmethod def workflow_invoke(tool: Tool, tool_parameters: dict, @@ -146,12 +148,18 @@ class ToolEngine: Invoke the tool with the given arguments. """ started_at = datetime.now(timezone.utc) - meta = ToolInvokeMeta(time_cost=0.0, error=None) + meta = ToolInvokeMeta(time_cost=0.0, error=None, tool_config={ + 'tool_name': tool.identity.name, + 'tool_provider': tool.identity.provider, + 'tool_provider_type': tool.tool_provider_type().value, + 'tool_parameters': deepcopy(tool.runtime.runtime_parameters), + 'tool_icon': tool.identity.icon + }) try: response = tool.invoke(user_id, tool_parameters) except Exception as e: meta.error = str(e) - raise ToolEngineInvokeError(meta=meta) + raise ToolEngineInvokeError(meta) finally: ended_at = datetime.now(timezone.utc) meta.time_cost = (ended_at - started_at).total_seconds() diff --git a/api/models/model.py b/api/models/model.py index fc0d53bcde..037b0aff43 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -1163,6 +1163,10 @@ class MessageAgentThought(db.Model): else: return [] + @property + def tools(self) -> list[str]: + return self.tool.split(";") if self.tool else [] + @property def tool_labels(self) -> dict: try: @@ -1182,6 +1186,55 @@ class MessageAgentThought(db.Model): return {} except Exception as e: return {} + + @property + def tool_inputs_dict(self) -> dict: + tools = self.tools + try: + if self.tool_input: + data = json.loads(self.tool_input) + result = {} + for tool in tools: + if tool in data: + result[tool] = data[tool] + else: + if len(tools) == 1: + result[tool] = data + else: + result[tool] = {} + return result + else: + return { + tool: {} for tool in tools + } + except Exception as e: + return {} + + @property + def tool_outputs_dict(self) -> dict: + tools = self.tools + try: + if self.observation: + data = json.loads(self.observation) + result = {} + for tool in tools: + if tool in data: + result[tool] = data[tool] + else: + if len(tools) == 1: + result[tool] = data + else: + result[tool] = {} + return result + else: + return { + tool: {} for tool in tools + } + except Exception as e: + if self.observation: + return { + tool: self.observation for tool in tools + } class DatasetRetrieverResource(db.Model): __tablename__ = 'dataset_retriever_resources' diff --git a/api/services/agent_service.py b/api/services/agent_service.py new file mode 100644 index 0000000000..b534817739 --- /dev/null +++ b/api/services/agent_service.py @@ -0,0 +1,123 @@ +from core.app.app_config.easy_ui_based_app.agent.manager import AgentConfigManager +from extensions.ext_database import db +from models.account import Account +from models.model import App, Conversation, EndUser, Message, MessageAgentThought +from services.tools_transform_service import ToolTransformService + + +class AgentService: + @classmethod + def get_agent_logs(cls, app_model: App, + conversation_id: str, + message_id: str) -> dict: + """ + Service to get agent logs + """ + conversation: Conversation = db.session.query(Conversation).filter( + Conversation.id == conversation_id, + Conversation.app_id == app_model.id, + ).first() + + if not conversation: + raise ValueError(f"Conversation not found: {conversation_id}") + + message: Message = db.session.query(Message).filter( + Message.id == message_id, + Message.conversation_id == conversation_id, + ).first() + + if not message: + raise ValueError(f"Message not found: {message_id}") + + agent_thoughts: list[MessageAgentThought] = message.agent_thoughts + + if conversation.from_end_user_id: + # only select name field + executor = db.session.query(EndUser, EndUser.name).filter( + EndUser.id == conversation.from_end_user_id + ).first() + else: + executor = db.session.query(Account, Account.name).filter( + Account.id == conversation.from_account_id + ).first() + + if executor: + executor = executor.name + else: + executor = 'Unknown' + + result = { + 'meta': { + 'status': 'success', + 'executor': executor, + 'start_time': message.created_at.isoformat(), + 'elapsed_time': message.provider_response_latency, + 'total_tokens': message.answer_tokens + message.message_tokens, + 'agent_mode': app_model.app_model_config.agent_mode_dict.get('strategy', 'react'), + 'iterations': len(agent_thoughts), + }, + 'iterations': [], + 'files': message.files, + } + + agent_config = AgentConfigManager.convert(app_model.app_model_config.to_dict()) + agent_tools = agent_config.tools + + def find_agent_tool(tool_name: str): + for agent_tool in agent_tools: + if agent_tool.tool_name == tool_name: + return agent_tool + + for agent_thought in agent_thoughts: + tools = agent_thought.tools + tool_labels = agent_thought.tool_labels + tool_meta = agent_thought.tool_meta + tool_inputs = agent_thought.tool_inputs_dict + tool_outputs = agent_thought.tool_outputs_dict + tool_calls = [] + for tool in tools: + tool_name = tool + tool_label = tool_labels.get(tool_name, tool_name) + tool_input = tool_inputs.get(tool_name, {}) + tool_output = tool_outputs.get(tool_name, {}) + tool_meta_data = tool_meta.get(tool_name, {}) + tool_config = tool_meta_data.get('tool_config', {}) + tool_icon = ToolTransformService.get_tool_provider_icon_url( + provider_type=tool_config.get('tool_provider_type', ''), + provider_name=tool_config.get('tool_provider', ''), + icon=tool_config.get('tool_icon', '') + ) + if not tool_icon: + tool_entity = find_agent_tool(tool_name) + if tool_entity: + tool_icon = ToolTransformService.get_tool_provider_icon_url( + provider_type=tool_entity.provider_type, + provider_name=tool_entity.provider_id, + icon='' + ) + + tool_calls.append({ + 'status': 'success' if not tool_meta_data.get('error') else 'error', + 'error': tool_meta_data.get('error'), + 'time_cost': tool_meta_data.get('time_cost', 0), + 'tool_name': tool_name, + 'tool_label': tool_label, + 'tool_input': tool_input, + 'tool_output': tool_output, + 'tool_parameters': tool_meta_data.get('tool_parameters', {}), + 'tool_icon': tool_icon, + }) + + result['iterations'].append({ + 'tokens': agent_thought.tokens, + 'tool_calls': tool_calls, + 'tool_raw': { + 'inputs': agent_thought.tool_input, + 'outputs': agent_thought.observation, + }, + 'thought': agent_thought.thought, + 'created_at': agent_thought.created_at.isoformat(), + 'files': agent_thought.files, + }) + + return result \ No newline at end of file