├── mint_agent ├── agent_api │ ├── __init__.py │ ├── CredentialManager.py │ └── messages.py ├── agent_graph │ ├── nodes │ │ ├── __init__.py │ │ ├── output_parser.py │ │ ├── gear_manager.py │ │ ├── tool_permit.py │ │ ├── llm_call.py │ │ └── history_manager.py │ ├── __init__.py │ └── graph.py ├── agent_state │ ├── __init__.py │ └── state.py ├── utils │ ├── graph_schema.png │ ├── errors.py │ ├── generate_credentials.py │ ├── AgentLogger.py │ └── chat.html ├── tools │ ├── MintHCM │ │ ├── __init__.py │ │ ├── GetRelationships.py │ │ ├── GetModuleFields.py │ │ ├── CreateRelationships.py │ │ ├── GetModuleNames.py │ │ ├── DeleteRelationships.py │ │ ├── DeleteRecord.py │ │ ├── CreateRecord.py │ │ ├── UpdateFields.py │ │ ├── BaseTool.py │ │ ├── GetUsers.py │ │ ├── CreateMeeting.py │ │ ├── Search.py │ │ └── SuiteAPI.py │ ├── CalendarTool.py │ ├── ToolController.py │ └── AvailabilityTool.py ├── credentials.json_example ├── llm │ ├── BaseController.py │ ├── AnthropicController.py │ ├── OpenAIController.py │ └── ChatFactory.py ├── prompts │ └── PromptController.py ├── server.py ├── AgentMint.py └── database │ └── db_utils.py ├── .gitignore ├── SECURITY.md ├── LICENSE ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .env_example ├── pyproject.toml ├── CODE_OF_CONDUCT.md └── README.md /mint_agent/agent_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mint_agent/agent_graph/nodes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mint_agent/agent_graph/__init__.py: -------------------------------------------------------------------------------- 1 | # This file marks the directory as a Python package 2 | -------------------------------------------------------------------------------- /mint_agent/agent_state/__init__.py: -------------------------------------------------------------------------------- 1 | # This file marks the directory as a Python package 2 | -------------------------------------------------------------------------------- /mint_agent/agent_graph/nodes/output_parser.py: -------------------------------------------------------------------------------- 1 | def output_parser(state): 2 | return state 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .env 3 | .conda 4 | __pycache__ 5 | AccessToken.txt 6 | credentials.json 7 | ruff.toml -------------------------------------------------------------------------------- /mint_agent/utils/graph_schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eVolpe-AI/AI-HR-Agent/HEAD/mint_agent/utils/graph_schema.png -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "CreateMeeting", 3 | "CreateRecord", 4 | "GetModuleFields", 5 | "GetModuleNames", 6 | "GetUsers", 7 | "Search" 8 | ] -------------------------------------------------------------------------------- /mint_agent/agent_graph/nodes/gear_manager.py: -------------------------------------------------------------------------------- 1 | from mint_agent.prompts.PromptController import PromptController 2 | 3 | 4 | def gear_manager(state): 5 | if state["system_prompt"] is None: 6 | new_prompt = PromptController.get_default_prompt(state["user"]) 7 | return {"system_prompt": new_prompt} 8 | return state 9 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Please report any vulnerabilities that you find via Security Advisories section https://github.com/eVolpe-AI/AI-HR-Agent/security/advisories We will reply to your report within 1 business day. We kindly ask you not to make the vulerability public for 14 days after we released a security update addressing it. 4 | -------------------------------------------------------------------------------- /mint_agent/utils/errors.py: -------------------------------------------------------------------------------- 1 | class AgentError(Exception): 2 | def __init__(self, message: str) -> None: 3 | self.message = message 4 | super().__init__(self.message) 5 | 6 | 7 | class ServerError(Exception): 8 | def __init__(self, message: str): 9 | self.message = message 10 | super().__init__(self.message) 11 | -------------------------------------------------------------------------------- /mint_agent/credentials.json_example: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "", 4 | "mint_user_id": "", 5 | "user_credentials": [ 6 | { 7 | "system": "MintHCM", 8 | "credential_type": "APIv8", 9 | "credentials": { 10 | "client_id": "", 11 | "secret": "" 12 | } 13 | } 14 | ] 15 | } 16 | ] -------------------------------------------------------------------------------- /mint_agent/agent_graph/nodes/tool_permit.py: -------------------------------------------------------------------------------- 1 | from langchain_core.callbacks.manager import adispatch_custom_event 2 | 3 | 4 | async def tool_permit(state): 5 | tool_to_use = state["messages"][-1].tool_calls[0] 6 | tool_name = tool_to_use["name"] 7 | tool_input = tool_to_use["args"] 8 | 9 | new_state = state.copy() 10 | 11 | if tool_name not in state["safe_tools"]: 12 | new_state["tool_accept"] = 0 13 | await adispatch_custom_event( 14 | "tool_accept", {"tool": tool_name, "params": tool_input} 15 | ) 16 | else: 17 | new_state["tool_accept"] = 1 18 | 19 | return new_state 20 | -------------------------------------------------------------------------------- /mint_agent/tools/CalendarTool.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import Optional, Type 3 | 4 | from langchain.callbacks.manager import ( 5 | CallbackManagerForToolRun, 6 | ) 7 | from langchain.pydantic_v1 import BaseModel, Field 8 | from langchain.tools import BaseTool 9 | 10 | 11 | class CalendarInput(BaseModel): 12 | format: str = Field( 13 | description=" Date format to be returned. Default is YYYY-MM-DD (Day)" 14 | ) 15 | 16 | 17 | class CalendarTool(BaseTool): 18 | name = "CalendarTool" 19 | description = "Useful for when you need to know current date. Input should be a date format. Always use this tool to get the current date if you are asked questions regarding today, yesterday, tomorrow etc." 20 | args_schema: Type[BaseModel] = CalendarInput 21 | # return_direct: bool = True 22 | 23 | def _run( 24 | self, format: str, run_manager: Optional[CallbackManagerForToolRun] = None 25 | ) -> str: 26 | """Use the tool.""" 27 | return date.today().strftime("%Y-%m-%d (%A)") 28 | -------------------------------------------------------------------------------- /mint_agent/llm/BaseController.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from langchain_core.messages import AIMessage 4 | 5 | 6 | class BaseController(ABC): 7 | """Abstract base class for controlling conversation models""" 8 | 9 | @abstractmethod 10 | async def get_output(self, messages) -> AIMessage: 11 | """ 12 | Get the output from the model for the given messages. 13 | 14 | Args: 15 | messages (list): A list of messages to send to the model. 16 | 17 | Returns: 18 | AIMessage: The response from the model. 19 | """ 20 | raise NotImplementedError 21 | 22 | # TODO async version causes asyncio error 23 | @abstractmethod 24 | def get_summary(self, messages) -> AIMessage: 25 | """ 26 | Get a summary of the given messages from the model. 27 | 28 | Args: 29 | messages (list): A list of messages to summarize. 30 | 31 | Returns: 32 | AIMessage: The summary response from the model. 33 | """ 34 | raise NotImplementedError 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 eVolpe AI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mint_agent/agent_graph/nodes/llm_call.py: -------------------------------------------------------------------------------- 1 | from langchain_core.messages import SystemMessage 2 | 3 | from mint_agent.llm.ChatFactory import ChatFactory 4 | from mint_agent.tools.ToolController import ToolController 5 | from mint_agent.utils.errors import AgentError 6 | 7 | 8 | async def llm_call(state): 9 | messages = state["messages"] 10 | conversation_summary = state["conversation_summary"] 11 | 12 | model_name = state["model_name"] 13 | provider = state["provider"] 14 | tools = ToolController.get_tools() 15 | 16 | if conversation_summary is not None: 17 | system_prompt = f"{state["system_prompt"]} This is summary of our conversation so far: {conversation_summary}" 18 | else: 19 | system_prompt = state["system_prompt"] 20 | 21 | messages_for_llm = [SystemMessage(content=system_prompt), *messages] 22 | 23 | try: 24 | model = ChatFactory.get_model_controller(provider, model_name, tools) 25 | response = await model.get_output(messages_for_llm) 26 | except Exception as e: 27 | raise AgentError("Failed to call LLM model") from e 28 | 29 | return {"messages": response} 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'Community - ' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | ## 🔍 What's the problem? 13 | 14 | 15 | ## 💡 Proposed Solution 16 | 17 | 18 | ## 🔄 Alternatives Considered 19 | 20 | 21 | ## 📝 Additional context 22 | 23 | -------------------------------------------------------------------------------- /.env_example: -------------------------------------------------------------------------------- 1 | # OpenAI API 2 | OPENAI_API_KEY = --required when using OpenAI LLM 3 | 4 | # Anthropic API 5 | ANTHROPIC_API_KEY = --required when using Anthropic LLM 6 | 7 | # Langchain API 8 | LANGCHAIN_TRACING_V2 = --required 9 | LANGCHAIN_PROJECT = 10 | LANGCHAIN_API_KEY = 11 | 12 | # LLM settings 13 | LLM_PROVIDER = ANTHROPIC --required 14 | LLM_MODEL = claude-3-haiku-20240307 --required 15 | LLM_ANSWER_LANGUAGE = english --required 16 | 17 | # Agent mongo database 18 | MONGO_URI = --required 19 | MONGO_DB_NAME = --required 20 | 21 | # MintHCM mysql database 22 | MINTDB_URI = --required 23 | MINTDB_PORT = --required 24 | MINTDB_USER = --required 25 | MINTDB_PASS = --required 26 | MINTDB_DATABASE_NAME = --required 27 | 28 | # MintHCM API 29 | MINT_API_URL = --required 30 | 31 | # Agent API settings 32 | API_IP = --required 33 | API_PORT = --required 34 | 35 | # Logging configuration 36 | LOG_LEVEL = --required 37 | LOG_TO_CONSOLE = --required 38 | LOG_FILE = e.g. /tmp/agent.log --required 39 | LOG_COLORING = --required 40 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/GetRelationships.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type 2 | 3 | from langchain.callbacks.manager import CallbackManagerForToolRun 4 | from langchain_core.runnables.config import RunnableConfig 5 | from langchain_core.tools import BaseTool 6 | from pydantic import BaseModel, Field 7 | 8 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 9 | 10 | 11 | class MintGetRelInput(BaseModel): 12 | record_id: str = Field( 13 | ..., description="ID of the record to get relationships from" 14 | ) 15 | related_module: str = Field(..., description="Name of the related module") 16 | 17 | 18 | class MintGetRelTool(BaseTool, MintBaseTool): 19 | name: str = "MintGetRelTool" 20 | description: str = "Tool to get relationships between records in MintHCM modules" 21 | args_schema: Type[BaseModel] = MintGetRelInput 22 | 23 | def _run( 24 | self, 25 | record_id: str, 26 | related_module: str, 27 | config: RunnableConfig, 28 | run_manager: Optional[CallbackManagerForToolRun] = None, 29 | ) -> Dict[str, Any]: 30 | try: 31 | suitecrm = self.get_connection(config) 32 | suitecrm.Meetings.get_relationship(record_id, related_module) 33 | return {"status": "success"} 34 | except Exception as e: 35 | return {"status": "error", "message": str(e)} 36 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/GetModuleFields.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type 2 | 3 | from langchain.callbacks.manager import CallbackManagerForToolRun 4 | from langchain_core.runnables.config import RunnableConfig 5 | from langchain_core.tools import BaseTool, ToolException 6 | from pydantic import BaseModel, Field 7 | 8 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 9 | from mint_agent.tools.MintHCM.SuiteAPI import Module 10 | 11 | 12 | class MintGetModuleFieldsInput(BaseModel): 13 | module_name: str = Field( 14 | ..., 15 | description="Name of the module in Mint in which the information is to be read", 16 | ) 17 | 18 | 19 | class MintGetModuleFieldsTool(BaseTool, MintBaseTool): 20 | name: str = "MintGetModuleFieldsTool" 21 | description: str = """ 22 | Tool to retrieve list of fields and their types in a MintHCM module. 23 | Use This tool ALWAYS before using MintSearchTool to get list of fields available in the module. 24 | """ 25 | args_schema: Type[BaseModel] = MintGetModuleFieldsInput 26 | 27 | def _run( 28 | self, 29 | module_name: str, 30 | config: RunnableConfig, 31 | run_manager: Optional[CallbackManagerForToolRun] = None, 32 | ) -> Dict[str, Any]: 33 | try: 34 | suitecrm = self.get_connection(config) 35 | module = Module(suitecrm, module_name) 36 | fields = module.fields() 37 | return {"fields": fields} 38 | except Exception as e: 39 | raise ToolException(f"Error: {e}") 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'Community Bug - ' 5 | labels: bug 6 | assignees: AleksanderBak 7 | 8 | --- 9 | 10 | 11 | 12 | ## ✅ Expected Behavior 13 | 14 | 15 | ## 🛑 Current Behavior 16 | 17 | 18 | ## 💡 Possible Solution 19 | 20 | 21 | ## 🔄 Steps to Reproduce 22 | 23 | 24 | 1. 25 | 2. 26 | 3. 27 | 28 | ## 🌍 Context (Environment) 29 | 30 | 31 | 32 | ## 📝 Detailed Description 33 | 34 | 35 | ## 🚀Possible Implementation 36 | 37 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/CreateRelationships.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type 2 | 3 | from langchain.callbacks.manager import CallbackManagerForToolRun 4 | from langchain_core.runnables.config import RunnableConfig 5 | from langchain_core.tools import BaseTool 6 | from pydantic import BaseModel, Field 7 | 8 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 9 | 10 | 11 | class MintCreateRelInput(BaseModel): 12 | record_id: str = Field( 13 | ..., description="ID of the record to create a relationship from" 14 | ) 15 | related_module: str = Field(..., description="Name of the related module") 16 | related_id: str = Field(..., description="ID of the related record") 17 | 18 | 19 | class MintCreateRelTool(BaseTool, MintBaseTool): 20 | name: str = "MintCreateRelTool" 21 | description: str = """Tool to create a relationship between records in MintHCM modules. Firstly, you need to get both record_id and related_id by 22 | using MintSearchTool""" 23 | args_schema: Type[BaseModel] = MintCreateRelInput 24 | 25 | def _run( 26 | self, 27 | record_id: str, 28 | related_module: str, 29 | related_id: str, 30 | config: RunnableConfig, 31 | run_manager: Optional[CallbackManagerForToolRun] = None, 32 | ) -> Dict[str, Any]: 33 | try: 34 | suitecrm = self.get_connection(config) 35 | result = suitecrm.Meetings.create_relationship( 36 | record_id, related_module, related_id 37 | ) 38 | return {"status": "success", "result": result} 39 | except Exception as e: 40 | return {"status": "error", "message": str(e)} 41 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/GetModuleNames.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | from langchain_core.runnables.config import RunnableConfig 4 | from langchain_core.tools import BaseTool 5 | 6 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 7 | 8 | 9 | class MintGetModuleNamesTool(BaseTool, MintBaseTool): 10 | name: str = "MintGetModuleNamesTool" 11 | description: str = """ 12 | Tool to retrieve list of module names. Use this tool only if user asks for list of available modules or to check if specific module exists 13 | """ 14 | 15 | module_blacklist: List[str] = [] 16 | use_blacklist: bool = False 17 | 18 | module_whitelist: List[str] = [ 19 | "Meetings", 20 | "Tasks", 21 | "Certificates", 22 | "Responsibilities", 23 | "Calls", 24 | "Candidates", 25 | "Candidatures", 26 | "Benefits", 27 | ] 28 | use_whitelist: bool = True 29 | 30 | def _run( 31 | self, 32 | config: RunnableConfig, 33 | ) -> Dict[str, Any]: 34 | try: 35 | suitecrm = self.get_connection(config) 36 | modules = suitecrm.get_modules() 37 | 38 | if self.use_blacklist: 39 | modules = [ 40 | module for module in modules if module not in self.module_blacklist 41 | ] 42 | 43 | if self.use_whitelist: 44 | modules = [ 45 | module for module in modules if module in self.module_whitelist 46 | ] 47 | 48 | return modules 49 | except Exception as e: 50 | return f"Error occured while trying to get list of modules: {e}" 51 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/DeleteRelationships.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type 2 | 3 | from langchain.callbacks.manager import CallbackManagerForToolRun 4 | from langchain_core.runnables.config import RunnableConfig 5 | from langchain_core.tools import BaseTool 6 | from pydantic import BaseModel, Field 7 | 8 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 9 | 10 | 11 | class MintDeleteRelInput(BaseModel): 12 | record_id: str = Field( 13 | ..., description="ID of the record to delete a relationship from" 14 | ) 15 | related_module: str = Field(..., description="Name of the related module") 16 | related_id: str = Field(..., description="ID of the related record") 17 | 18 | 19 | class MintDeleteRelTool(BaseTool, MintBaseTool): 20 | name: str = "MintDeleteRelTool" 21 | description: str = """Tool to delete a relationship between records in MintHCM modules. If you don't know ID numbers, you need to get 22 | both record_id and related_id by using MintSearchTool""" 23 | args_schema: Type[BaseModel] = MintDeleteRelInput 24 | 25 | def _run( 26 | self, 27 | record_id: str, 28 | related_module: str, 29 | related_id: str, 30 | config: RunnableConfig, 31 | run_manager: Optional[CallbackManagerForToolRun] = None, 32 | ) -> Dict[str, Any]: 33 | try: 34 | suitecrm = self.get_connection(config) 35 | result = suitecrm.Meetings.delete_relationship( 36 | record_id, related_module, related_id 37 | ) 38 | return {"status": "success", "result": result} 39 | except Exception as e: 40 | return {"status": "error", "message": str(e)} 41 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/DeleteRecord.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type 2 | 3 | from langchain.callbacks.manager import CallbackManagerForToolRun 4 | from langchain_core.runnables.config import RunnableConfig 5 | from langchain_core.tools import BaseTool, ToolException 6 | from pydantic import BaseModel, Field 7 | 8 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 9 | 10 | 11 | class MintDeleteDataInput(BaseModel): 12 | module_name: str = Field( 13 | ..., 14 | description="Name of the module in Mint in which the record is to be deleted", 15 | ) 16 | id: Any = Field(..., description="ID number of the record to be deleted") 17 | 18 | 19 | class MintDeleteRecordTool(BaseTool, MintBaseTool): 20 | name: str = "MintDeleteRecordTool" 21 | description: str = """General Tool to delete records in MintHCM modules, for example employees, candidates, meetings etc. 22 | Dont use this tool without knowing the fields available in the module. Use MintGetModuleFieldsTool to get list of fields available in the module. 23 | Use MintSearchTool to retrieve ID of the record""" 24 | args_schema: Type[BaseModel] = MintDeleteDataInput 25 | 26 | def _run( 27 | self, 28 | module_name: str, 29 | id: str, 30 | config: RunnableConfig, 31 | run_manager: Optional[CallbackManagerForToolRun] = None, 32 | ) -> Dict[str, Any]: 33 | try: 34 | suitecrm = self.get_connection(config) 35 | url = f"{self.api_url}/module/{module_name}/{id}" 36 | suitecrm.request(url, "delete") 37 | return f"Record with id: {id} has been deleted from module {module_name}" 38 | except Exception as e: 39 | raise ToolException(f"Error: {e}") 40 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/CreateRecord.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type 2 | 3 | from langchain.callbacks.manager import CallbackManagerForToolRun 4 | from langchain_core.runnables.config import RunnableConfig 5 | from langchain_core.tools import BaseTool, ToolException 6 | from pydantic import BaseModel, Field 7 | 8 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 9 | 10 | 11 | class MintCreateDataInput(BaseModel): 12 | module_name: str = Field( 13 | ..., 14 | description="Name of the module in Mint in which the record is to be created", 15 | ) 16 | attributes: Dict[str, Any] = Field( 17 | ..., description="Record attributes in key-value format" 18 | ) 19 | 20 | 21 | class MintCreateRecordTool(BaseTool, MintBaseTool): 22 | name: str = "MintCreateRecordTool" 23 | description: str = """General Tool to create new record in MintHCM modules, for example new employees, new candidates etc. 24 | Dont use this tool for meetings. Use MintCreateMeetingTool for meetings. 25 | Dont use this tool without knowing the fields available in the module. Use MintGetModuleFieldsTool to get list of fields available in the module.""" 26 | args_schema: Type[BaseModel] = MintCreateDataInput 27 | 28 | def _run( 29 | self, 30 | module_name: str, 31 | attributes: Dict[str, Any], 32 | config: RunnableConfig, 33 | run_manager: Optional[CallbackManagerForToolRun] = None, 34 | ) -> Dict[str, Any]: 35 | try: 36 | suitecrm = self.get_connection(config) 37 | url = f"{self.api_url}/module" 38 | data = {"type": module_name, "attributes": attributes} 39 | suitecrm.request(url, "post", parameters=data) 40 | return f"New record created for module: {module_name}" 41 | except Exception as e: 42 | raise ToolException(f"Error: {e}") 43 | -------------------------------------------------------------------------------- /mint_agent/llm/AnthropicController.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from langchain_anthropic.chat_models import ChatAnthropic 4 | from langchain_core.messages import AIMessage 5 | 6 | from mint_agent.llm.BaseController import BaseController 7 | 8 | DEFAULT_MODEL = "claude-3-haiku-20240307" 9 | DEFAULT_MAX_TOKENS = 1000 10 | 11 | 12 | class AnthropicController(BaseController): 13 | """Class to control conversation with Anthropic's Claude model""" 14 | 15 | def __init__( 16 | self, 17 | model_name: Optional[str] = DEFAULT_MODEL, 18 | api_key: Optional[str] = None, 19 | temperature: Optional[float] = 0.0, 20 | max_tokens: Optional[int] = DEFAULT_MAX_TOKENS, 21 | tools: Optional[list] = None, 22 | streaming: bool = True, 23 | ): 24 | """ 25 | Initialize the AnthropicController with the specified parameters. 26 | 27 | Args: 28 | model_name (Optional[str]): The name of the model to use. Defaults to DEFAULT_MODEL. 29 | api_key (Optional[str]): The API key for authentication. 30 | temperature (Optional[float]): The temperature setting for the model. Defaults to 0.0. 31 | max_tokens (Optional[int]): The maximum number of tokens for the model's output. Defaults to DEFAULT_MAX_TOKENS. 32 | tools (Optional[list]): A list of tools to bind to the model. Defaults to an empty list. 33 | streaming (bool): Whether to use streaming mode. Defaults to True. 34 | """ 35 | 36 | tools = tools or [] 37 | 38 | self.client = ChatAnthropic( 39 | anthropic_api_key=api_key, 40 | model=model_name, 41 | temperature=temperature, 42 | max_tokens=max_tokens, 43 | streaming=streaming, 44 | ).bind_tools(tools) 45 | 46 | async def get_output(self, messages: list) -> AIMessage: 47 | return await self.client.ainvoke(messages) 48 | 49 | def get_summary(self, messages: list) -> AIMessage: 50 | config = { 51 | "tags": ["silent"], 52 | } 53 | return self.client.invoke(messages, config) 54 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/UpdateFields.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type 2 | 3 | from langchain.callbacks.manager import CallbackManagerForToolRun 4 | from langchain_core.runnables.config import RunnableConfig 5 | from langchain_core.tools import BaseTool, ToolException 6 | from pydantic import BaseModel, Field 7 | 8 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 9 | 10 | 11 | class MintUpdateDataInput(BaseModel): 12 | module_name: str = Field( 13 | ..., 14 | description="Name of the module in MintHCM system. If you don't know the module, use MintSearchTool to search for module name in MintHCM.", 15 | ) 16 | id: str = Field( 17 | ..., 18 | description="ID number of the record to update. If you don't know id, use MintSearchTool to search for record id in MintHCM.", 19 | ) 20 | attributes: Dict[str, Any] = Field( 21 | ..., description="Attributes to update in key-value format" 22 | ) 23 | 24 | 25 | class MintUpdateFieldsTool(BaseTool, MintBaseTool): 26 | name = "MintUpdateFieldsTool" 27 | description = """Use this tool to update fields in the module based on data received from the user. Use this tool to update value of the field in the module, 28 | for example, duration or start date. Before using MintUpdateFieldsTool, ensure you have the correct module name and record ID. If not, use MintSearchTool to retrieve them. 29 | """ 30 | args_schema: Type[BaseModel] = MintUpdateDataInput 31 | 32 | def _run( 33 | self, 34 | module_name: str, 35 | id: str, 36 | attributes: Dict[str, Any], 37 | config: RunnableConfig, 38 | run_manager: Optional[CallbackManagerForToolRun] = None, 39 | ) -> Dict[str, Any]: 40 | try: 41 | suitecrm = self.get_connection(config) 42 | url = f"{self.api_url}/module" 43 | data = {"type": module_name, "id": id, "attributes": attributes} 44 | response = suitecrm.request(url, "patch", parameters=data) 45 | return "Updated field in module " + module_name + " with ID " + id 46 | 47 | except Exception as e: 48 | raise ToolException(f"Error: {e}") 49 | -------------------------------------------------------------------------------- /mint_agent/llm/OpenAIController.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from dotenv import load_dotenv 4 | from langchain_openai import ChatOpenAI 5 | 6 | from mint_agent.llm.BaseController import BaseController 7 | 8 | load_dotenv() 9 | 10 | 11 | class OpenAIController(BaseController): 12 | """Class to control conversation with OpenAI models""" 13 | 14 | DEFAULT_MODEL = "gpt-4o-mini-2024-07-18" 15 | DEFAULT_MAX_TOKENS = 1000 16 | DEFAULT_TEMPERATURE = 0.0 17 | DEFAULT_MAX_RETIRES = 2 18 | 19 | def __init__( 20 | self, 21 | model_name: Optional[str] = DEFAULT_MODEL, 22 | api_key: Optional[str] = None, 23 | temperature: Optional[float] = DEFAULT_TEMPERATURE, 24 | max_tokens: Optional[int] = DEFAULT_MAX_TOKENS, 25 | tools: Optional[list] = None, 26 | max_retries: Optional[int] = DEFAULT_MAX_RETIRES, 27 | streaming: bool = True, 28 | ): 29 | """ 30 | Initialize the OpenAI with the specified parameters. 31 | 32 | Args: 33 | model_name (Optional[str]): The name of the model to use. Defaults to DEFAULT_MODEL. 34 | api_key (Optional[str]): The API key for authentication. 35 | temperature (Optional[float]): The temperature setting for the model. Defaults to 0.0. 36 | max_tokens (Optional[int]): The maximum number of tokens for the model's output. Defaults to DEFAULT_MAX_TOKENS. 37 | tools (Optional[list]): A list of tools to bind to the model. Defaults to an empty list. 38 | max_retries (Optional[int]): The maximum number of retries for the model. Defaults to DEFAULT_MAX_RETIRES. 39 | streaming (bool): Whether to use streaming mode. Defaults to True. 40 | """ 41 | 42 | self.client = ChatOpenAI( 43 | model=model_name, 44 | temperature=temperature, 45 | max_retries=max_retries, 46 | api_key=api_key, 47 | max_tokens=max_tokens, 48 | streaming=streaming, 49 | ) 50 | 51 | if tools: 52 | self.client.bind_tools(tools) 53 | 54 | async def get_output(self, messages): 55 | return await self.client.ainvoke(messages) 56 | 57 | def get_summary(self, messages): 58 | config = { 59 | "tags": ["silent"], 60 | } 61 | return self.client.invoke(messages, config) 62 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/BaseTool.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABC, abstractmethod 3 | 4 | from dotenv import load_dotenv 5 | from loguru import logger 6 | 7 | from mint_agent.agent_api.CredentialManager import CredentialManager 8 | from mint_agent.tools.MintHCM.SuiteAPI import SuiteCRM 9 | 10 | load_dotenv() 11 | 12 | 13 | class BaseAgentTool(ABC): 14 | """ 15 | Abstract base class for agent tools. This class defines the interface that all agent 16 | tools must implement. 17 | """ 18 | 19 | @property 20 | @abstractmethod 21 | def system(self): 22 | """ 23 | Abstract property to get the system name. 24 | 25 | Returns: 26 | str: The name of the system. 27 | """ 28 | 29 | @property 30 | @abstractmethod 31 | def credential_type(self): 32 | """ 33 | Abstract property to get the credential type. 34 | 35 | Returns: 36 | str: The type of credentials. 37 | """ 38 | 39 | @abstractmethod 40 | def get_connection(self, config: dict) -> any: 41 | """ 42 | Abstract method to get a connection for a given user ID. 43 | 44 | Args: 45 | config (dict): The configuration of the agent graph. 46 | 47 | Returns: 48 | any: The connection object. 49 | """ 50 | raise NotImplementedError 51 | 52 | 53 | class MintBaseTool(BaseAgentTool): 54 | """ 55 | Class providing the base implementation for connecting to the MintHCM system. 56 | 57 | Attributes: 58 | api_url (str): The URL of the MintHCM API. 59 | """ 60 | 61 | api_url = os.getenv("MINT_API_URL") 62 | 63 | @property 64 | def system(self): 65 | return "MintHCM" 66 | 67 | @property 68 | def credential_type(self): 69 | return "APIv8" 70 | 71 | def get_connection(self, config) -> SuiteCRM: 72 | try: 73 | user_id = config.get("configurable", {}).get("user_id") 74 | credential_manager = CredentialManager() 75 | client_id, client_secret = credential_manager.get_system_credentials( 76 | user_id=user_id, 77 | system=self.system, 78 | credential_type=self.credential_type, 79 | ) 80 | if not client_id or not client_secret: 81 | raise ValueError( 82 | f"Client ID or client secret not found for user ID: {user_id}" 83 | ) 84 | return SuiteCRM( 85 | client_id=client_id, client_secret=client_secret, url=self.api_url 86 | ) 87 | except Exception as e: 88 | raise e 89 | -------------------------------------------------------------------------------- /mint_agent/agent_graph/graph.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from langgraph.graph import END, START, StateGraph 4 | from langgraph.graph.graph import CompiledGraph 5 | from langgraph.prebuilt import ToolNode 6 | from motor.motor_asyncio import AsyncIOMotorClient 7 | 8 | from mint_agent.agent_graph.nodes.gear_manager import gear_manager 9 | from mint_agent.agent_graph.nodes.history_manager import history_manager 10 | from mint_agent.agent_graph.nodes.llm_call import llm_call 11 | from mint_agent.agent_graph.nodes.tool_permit import tool_permit 12 | from mint_agent.agent_state.state import GraphState 13 | from mint_agent.database.db_utils import MongoDBCheckpointSaver 14 | 15 | 16 | def should_continue(state) -> str: 17 | messages = state["messages"] 18 | last_message = messages[-1] 19 | if not last_message.tool_calls: 20 | return "end" 21 | else: 22 | return "continue" 23 | 24 | 25 | def check_user_decision(state) -> str: 26 | decision = state["tool_accept"] 27 | if decision: 28 | return "safe" 29 | else: 30 | return "unsafe" 31 | 32 | 33 | def check_message_type(state) -> str: 34 | if state["tool_accept"]: 35 | return "confirmation" 36 | else: 37 | return "default" 38 | 39 | 40 | def create_graph(tools: list) -> StateGraph: 41 | graph = StateGraph(GraphState) 42 | 43 | graph.add_node("llm_node", llm_call) 44 | graph.add_node("tool_node", ToolNode(tools)) 45 | graph.add_node("tool_controller_node", tool_permit) 46 | graph.add_node("gear_manager_node", gear_manager) 47 | graph.add_node("history_manager_node", history_manager) 48 | 49 | graph.add_conditional_edges( 50 | START, 51 | check_message_type, 52 | {"default": "gear_manager_node", "confirmation": "tool_node"}, 53 | ) 54 | graph.add_edge("gear_manager_node", "history_manager_node") 55 | graph.add_edge("history_manager_node", "llm_node") 56 | graph.add_conditional_edges( 57 | "llm_node", 58 | should_continue, 59 | {"continue": "tool_controller_node", "end": END}, 60 | ) 61 | graph.add_conditional_edges( 62 | "tool_controller_node", 63 | check_user_decision, 64 | {"safe": "tool_node", "unsafe": END}, 65 | ) 66 | graph.add_edge("tool_node", "llm_node") 67 | 68 | return graph 69 | 70 | 71 | def compile_workflow(graph: StateGraph, username: str) -> CompiledGraph: 72 | MONGO_URI = os.getenv("MONGO_URI") 73 | DB_NAME = os.getenv("MONGO_DB_NAME") 74 | checkpointer = MongoDBCheckpointSaver( 75 | AsyncIOMotorClient(MONGO_URI), DB_NAME, username 76 | ) 77 | workflow = graph.compile(checkpointer=checkpointer) 78 | 79 | return workflow 80 | -------------------------------------------------------------------------------- /mint_agent/llm/ChatFactory.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from mint_agent.llm.AnthropicController import AnthropicController 4 | from mint_agent.llm.OpenAIController import OpenAIController 5 | 6 | 7 | class ProviderConfig: 8 | """ 9 | Configuration class for different providers. 10 | 11 | This class holds configuration details for various providers and allows 12 | retrieval of specific parameters for each provider. 13 | """ 14 | 15 | config = { 16 | "ANTHROPIC": {"returns_usage_data": True}, 17 | "OPENAI": {"returns_usage_data": False}, 18 | } 19 | 20 | @staticmethod 21 | def get_param(provider: str, param: str) -> any: 22 | """ 23 | Retrieve a specific parameter for a given provider. 24 | 25 | Args: 26 | provider (str): The name of the provider. 27 | param (str): The parameter to retrieve. 28 | 29 | Returns: 30 | any: The value of the requested parameter. 31 | 32 | Raises: 33 | ValueError: If the provider or parameter is not supported. 34 | """ 35 | if provider not in ProviderConfig.config: 36 | raise ValueError(f"Provider {provider} not supported") 37 | if param not in ProviderConfig.config[provider]: 38 | raise ValueError(f"Param {param} not supported for provider {provider}") 39 | return ProviderConfig.config[provider][param] 40 | 41 | 42 | class ChatFactory: 43 | model_controllers = { 44 | "ANTHROPIC": AnthropicController, 45 | "OPENAI": OpenAIController, 46 | } 47 | 48 | models = { 49 | "ANTHROPIC": [ 50 | "claude-3-haiku-20240307", 51 | # "claude-3-sonnet-20240229", 52 | # "claude-3-opus-20240229", 53 | ], 54 | "OPENAI": [ 55 | "gpt-4o-mini-2024-07-18" 56 | # "gpt4o", 57 | ], 58 | } 59 | 60 | @staticmethod 61 | def get_model_controller( 62 | provider: str, model_name: str, tools: Optional[list] = None 63 | ): 64 | tools = tools or None 65 | 66 | if provider not in ChatFactory.model_controllers: 67 | raise ValueError(f"Model provider {provider} not supported") 68 | 69 | if model_name not in ChatFactory.models[provider]: 70 | raise ValueError( 71 | f"Model {model_name} not supported for provider {provider}" 72 | ) 73 | 74 | controller_class = ChatFactory.model_controllers[provider] 75 | return controller_class(model_name=model_name, tools=tools) 76 | 77 | @staticmethod 78 | def get_models(provider: str) -> list[str]: 79 | return ChatFactory.models[provider] 80 | 81 | @staticmethod 82 | def get_providers() -> list[str]: 83 | return list(ChatFactory.models.keys()) 84 | -------------------------------------------------------------------------------- /mint_agent/prompts/PromptController.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | 9 | class PromptController: 10 | simple: str = "Answer only in {language}" 11 | 12 | default: str = """ 13 | User you are talking to is a user of MintHCM with username {username}. 14 | Always answer in {language}. 15 | You are a helpful assistant. You have access to several tools. Always check with CalendarTool what day it is today. 16 | Your task is to provide accurate and relevant information to the user. 17 | Use tools to get additional information and provide the user with the most relevant answer. 18 | Make sure to verify the information before providing it to the user. 19 | If using MintHCM tools, always make sure to use the correct field names and types by using MintSearchTool. 20 | Do not make up information! Do not rely on your knowledge, always use the tools to get the most accurate information. 21 | If asked for holidays and events, make sure you know which country the questions regards and search for them with the search tool. 22 | Do no assume you know what day is now. If you are asked questions regarding today, yesterday, tomorrow etc. then always use the CalendarTool to get the current date. 23 | Some questions may require you to use multiple tools. Think carefully what information you need to best answer and use tools accordingly or ask additional questions to the user. 24 | When listing meetings or calls use tabular format. 25 | """ 26 | 27 | @staticmethod 28 | def get_simple_prompt() -> str: 29 | language = os.getenv("LLM_ANSWER_LANGUAGE", "english") 30 | return PromptController.simple.format(language=language) 31 | 32 | @staticmethod 33 | def get_default_prompt(username: str) -> str: 34 | language = os.getenv("LLM_ANSWER_LANGUAGE", "english") 35 | return PromptController.default.format(username=username, language=language) 36 | 37 | @staticmethod 38 | def get_summary_prompt(prev_summary: Optional[str]) -> str: 39 | if prev_summary is not None: 40 | return f""" 41 | This is the current conversation summary: {prev_summary}. 42 | Based on this and the messages available in the history, create a new brief summary. 43 | Write it in the form of continuous text and do not add any introduction. 44 | Skip describing the request for the summary; it's not important information. 45 | """ 46 | return """ 47 | Create a brief summary of the above conversation. Skip describing the request for the summary; it's not important information. Write only the summary in the form of continuous text. 48 | Do not add any introduction like 'This is the current conversation summary:'. 49 | """ 50 | -------------------------------------------------------------------------------- /mint_agent/utils/generate_credentials.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import inquirer 4 | from cryptography.fernet import Fernet 5 | from dotenv import load_dotenv, set_key 6 | from pymongo import MongoClient 7 | from termcolor import colored 8 | 9 | load_dotenv() 10 | 11 | 12 | def generate_encryption_key(): 13 | key = os.getenv("FERNET_KEY") 14 | if key: 15 | print("Encryption key already exists in .env file") 16 | return 17 | key = Fernet.generate_key() 18 | set_key(".env", "FERNET_KEY", key.decode()) 19 | print("Encryption key generated and saved to .env file") 20 | 21 | 22 | def connect_to_db(): 23 | try: 24 | db_uri = os.getenv("MONGO_URI") 25 | db_name = os.getenv("MONGO_DB_NAME") 26 | client = MongoClient(db_uri) 27 | db = client[db_name] 28 | return db 29 | except Exception as e: 30 | raise ConnectionError(f"Failed to connect to database: {e}") 31 | 32 | 33 | def collect_user_data(): 34 | KEY = os.getenv("FERNET_KEY").encode() 35 | user_id = inquirer.text(message="Enter user name") 36 | mint_user_id = inquirer.text(message="Enter user id from MintHCM") 37 | 38 | credentials = [] 39 | add_credentials = inquirer.confirm( 40 | message=f"Add system credentials for {colored(user_id,"yellow")}?", 41 | default=False, 42 | ) 43 | 44 | if add_credentials: 45 | client_id = inquirer.text(message="Enter API client_id") 46 | secret = inquirer.text(message="Enter API client secret") 47 | 48 | f = Fernet(KEY) 49 | secret = f.encrypt(secret.encode()) 50 | 51 | credentials.append( 52 | { 53 | "system": "MintHCM", 54 | "credential_type": "APIv8", 55 | "credentials": { 56 | "client_id": client_id, 57 | "secret": secret, 58 | }, 59 | } 60 | ) 61 | 62 | return { 63 | "_id": user_id, 64 | "mint_user_id": mint_user_id, 65 | "user_credentials": credentials, 66 | } 67 | 68 | 69 | def generate_credentials(): 70 | db = connect_to_db() 71 | 72 | while True: 73 | user_data = collect_user_data() 74 | 75 | try: 76 | collection_name = user_data["_id"] 77 | collection = db[collection_name] 78 | query = {"_id": user_data["_id"], "mint_user_id": user_data["mint_user_id"]} 79 | 80 | credentials_to_save = ( 81 | user_data["user_credentials"] if "user_credentials" in user_data else {} 82 | ) 83 | 84 | update = {"$set": {"user_credentials": credentials_to_save}} 85 | collection.update_one(query, update, upsert=True) 86 | print(f"Credentials generated for user: {user_data['_id']}") 87 | except Exception as e: 88 | print(f"Error while generating credentials: {e}") 89 | 90 | add_more_users = inquirer.confirm( 91 | message="Add credentials for another user?", default=False 92 | ) 93 | if not add_more_users: 94 | break 95 | 96 | db.client.close() 97 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/GetUsers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Type 2 | 3 | from langchain.callbacks.manager import CallbackManagerForToolRun 4 | from langchain_core.runnables.config import RunnableConfig 5 | from langchain_core.tools import BaseTool, ToolException 6 | from pydantic import BaseModel, Field 7 | 8 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 9 | from mint_agent.tools.MintHCM.SuiteAPI import Module, SuiteCRM 10 | 11 | 12 | class MintGetUsersTool(BaseTool, MintBaseTool): 13 | name: str = "MintGetUsersTool" 14 | description: str = "Tool to retrieve list of users in MintHCM. Use this to get list of users in MintHCM and their details such as id, name, phone numbers, email, address etc." 15 | 16 | def _run( 17 | self, 18 | config: RunnableConfig, 19 | query_params: Optional[Dict[str, Any]] = None, 20 | run_manager: Optional[CallbackManagerForToolRun] = None, 21 | ) -> Dict[str, Any]: 22 | try: 23 | suitecrm = self.get_connection(config) 24 | module = Module(suitecrm, "Users") 25 | api_response = module.get_all() 26 | 27 | users = [] 28 | for user in api_response["data"]: 29 | user_id = user["id"] 30 | attributes = user["attributes"] 31 | 32 | name = attributes.get("full_name", "") 33 | 34 | phone_home = attributes.get("phone_home", "") 35 | phone_mobile = attributes.get("phone_mobile", "") 36 | phone_work = attributes.get("phone_work", "") 37 | phone_other = attributes.get("phone_other", "") 38 | 39 | address_street = attributes.get("address_street", "") 40 | address_city = attributes.get("address_city", "") 41 | address_state = attributes.get("address_state", "") 42 | address_country = attributes.get("address_country", "") 43 | address_postalcode = attributes.get("address_postalcode", "") 44 | 45 | user_type = attributes.get("UserType", "") 46 | employee_status = attributes.get("employee_status", "") 47 | 48 | email_address = attributes.get("email_addresses_primary", "") 49 | 50 | position = attributes.get("position_name", "") 51 | reports_to_id = attributes.get("reports_to_id", "") 52 | 53 | user_data = f"""ID: {user_id}, Name and surname: {name}, id of supervisor: {reports_to_id} 54 | Home phone: {phone_home}, Mobile phone: {phone_mobile}, Work phone: {phone_work}, Other phone: {phone_other}, Email: {email_address}, 55 | Address: (street: {address_street}, city: {address_city}, state: {address_state}, country: {address_country}, postal code: {address_postalcode}), 56 | User type: {user_type}, 57 | Employee status: {employee_status}, 58 | Position: {position}, 59 | """ 60 | users.append(user_data) 61 | return users 62 | 63 | except Exception as e: 64 | return f"While trying to get users, an error occurred: {e}" 65 | -------------------------------------------------------------------------------- /mint_agent/agent_api/CredentialManager.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from cryptography.fernet import Fernet 4 | from dotenv import load_dotenv 5 | from pymongo import MongoClient 6 | 7 | load_dotenv() 8 | 9 | 10 | class CredentialManager: 11 | """ 12 | Manages credentials required for agent workflow. 13 | """ 14 | 15 | def __init__(self): 16 | db_uri = os.getenv("MONGO_URI") 17 | db_name = os.getenv("MONGO_DB_NAME") 18 | self.client = MongoClient(db_uri) 19 | self.db = self.client[db_name] 20 | 21 | def authenticate_user(self, user_id: str, token: str) -> bool: 22 | """ 23 | Check if token exists in the database for the given user_id. 24 | 25 | Args: 26 | user_id (str): The ID of the user to authenticate. 27 | token (str): The authentication token for the user. 28 | 29 | Returns: 30 | bool: True if the user is authenticated, False otherwise. 31 | """ 32 | collection = self.db[user_id] 33 | user = collection.find_one({"_id": user_id, "mint_user_id": token}) 34 | return bool(user) 35 | 36 | def get_system_credentials( 37 | self, user_id: str, system: str, credential_type: str 38 | ) -> tuple: 39 | """ 40 | Retrieve credentials for a given user, system, and credential type. 41 | 42 | Args: 43 | user_id (str): The ID of the user whose credentials are being retrieved. 44 | system (str): The system for which the credentials are needed. 45 | credential_type (str): The type of credentials required. 46 | 47 | Returns: 48 | tuple: A tuple containing appropriate credentials for the user, system 49 | and credential type or tuple of None if not found or error occurred. 50 | 51 | """ 52 | KEY = os.getenv("FERNET_KEY").encode() 53 | f = Fernet(KEY) 54 | try: 55 | match system: 56 | case "MintHCM": 57 | if credential_type == "APIv8": 58 | collection = self.db[user_id] 59 | else: 60 | raise ValueError( 61 | f"Credential type '{credential_type}' not supported" 62 | ) 63 | 64 | user_data = collection.find_one( 65 | { 66 | "user_credentials": { 67 | "$elemMatch": { 68 | "system": system, 69 | "credential_type": credential_type, 70 | } 71 | } 72 | }, 73 | {"user_credentials.$": 1}, 74 | ) 75 | 76 | if user_data and "user_credentials" in user_data: 77 | credential = user_data["user_credentials"][0] 78 | client_id = credential["credentials"]["client_id"] 79 | secret = credential["credentials"]["secret"] 80 | return client_id, f.decrypt(secret).decode() 81 | raise ValueError("Credentials not found") 82 | case _: 83 | raise ValueError(f"System '{system}' not supported") 84 | except Exception: 85 | return None, None 86 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/CreateMeeting.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Type 2 | 3 | from langchain.callbacks.manager import CallbackManagerForToolRun 4 | from langchain_core.runnables.config import RunnableConfig 5 | from langchain_core.tools import BaseTool, ToolException 6 | from pydantic import BaseModel, Field 7 | 8 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 9 | 10 | 11 | class MintCreateMeetingInput(BaseModel): 12 | module_name: str = Field( 13 | ..., 14 | description="Name of the module in Mint in which the record is to be created", 15 | ) 16 | attributes: Dict[str, Any] = Field( 17 | ..., 18 | description=""" 19 | Record attributes in key-value format, value CAN NOT be a list. 20 | Example: { 'name': 'Meeting with John', 'date_start': '2022-01-01 12:00:00', 'date_end': '2022-01-01 13:00:00', 'assigned_user_id': '1'} 21 | """, 22 | ) 23 | attendees: List[str] = Field( 24 | ..., 25 | description=""" 26 | List of ids of attendees to the meeting. Example: ['1', 'f39a04c4-e537-4030-9d5a-6638bb2bb87d'] 27 | If you have just first_name and a last_name or username use MintSearchTool to search for user id in MintHCM. 28 | """, 29 | ) 30 | candidates: List[str] = Field( 31 | ..., 32 | description=""" 33 | List of ids of candidates to the meeting. Example: ['26c2081a-1f55-66f7-7b49-6662ef7ab388','68339282-a9f3-ed1d-7ffe-662f6fadd1a9'] 34 | If you have just first_name and a last_name of the candidate, use MintSearchTool to search for candidate id in MintHCM. 35 | """, 36 | ) 37 | 38 | 39 | class MintCreateMeetingTool(BaseTool, MintBaseTool): 40 | name: str = "MintCreateMeetingTool" 41 | description: str = """ 42 | Tool to create new meetings with attendees in MintHCM modules. 43 | Dont use this tool without knowing the fields available in the module. 44 | Use CalendarTool to get current_date and derive from it proper date_start and date_end for the meeting if asked to create meeting for today, tomorrow etc. 45 | Use MintGetModuleFieldsTool to get list of fields available in the module. 46 | """ 47 | args_schema: Type[BaseModel] = MintCreateMeetingInput 48 | 49 | def _run( 50 | self, 51 | module_name: str, 52 | attributes: Dict[str, Any], 53 | attendees: List[str], 54 | candidates: Optional[List[str]], 55 | config: RunnableConfig, 56 | run_manager: Optional[CallbackManagerForToolRun] = None, 57 | ) -> Dict[str, Any]: 58 | try: 59 | suitecrm = self.get_connection(config) 60 | url = f"{self.api_url}/module" 61 | data = {"type": module_name, "attributes": attributes} 62 | response = suitecrm.request(url, "post", parameters=data) 63 | 64 | def add_relationships(relationship_type, ids): 65 | for record_id in ids: 66 | relationship_url = f'{self.api_url}/module/{module_name}/{response["data"]["id"]}/relationships/{relationship_type}' 67 | relationship_data = { 68 | "type": relationship_type.capitalize(), 69 | "id": record_id, 70 | } 71 | suitecrm.request( 72 | relationship_url, "post", parameters=relationship_data 73 | ) 74 | 75 | if attendees: 76 | add_relationships("users", attendees) 77 | if candidates: 78 | add_relationships("candidates", candidates) 79 | 80 | return "New meeting created in module 'Meetings'." 81 | 82 | except Exception as e: 83 | raise ToolException(f"Error: {e}") 84 | -------------------------------------------------------------------------------- /mint_agent/agent_api/messages.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Dict, Optional 3 | 4 | from loguru import logger 5 | 6 | 7 | class UserMessageType(Enum): 8 | """ 9 | Enum representing the types of messages a user can send. 10 | 11 | Attributes: 12 | INPUT (str): An input message from the user. 13 | TOOL_CONFIRM (str): A confirmation message for a tool. 14 | TOOL_REJECT (str): A rejection message for a tool. 15 | """ 16 | 17 | INPUT = "input" 18 | TOOL_CONFIRM = "tool_confirm" 19 | TOOL_REJECT = "tool_reject" 20 | 21 | def __str__(self): 22 | return self.value 23 | 24 | 25 | class AgentMessageType(Enum): 26 | """ 27 | Enum representing the types of messages an agent can send. 28 | 29 | Attributes: 30 | AGENT_START (str): The start of the agent's processing. 31 | AGENT_END (str): The end of the agent's processing. 32 | LLM_START (str): The start of the LLM's processing. 33 | LLM_END (str): The end of the LLM's processing. 34 | LLM_TEXT (str): The text output from the LLM. 35 | ACCEPT_REQUEST (str): A request to accept a tool. 36 | TOOL_START (str): The start of the tool's processing. 37 | TOOL_END (str): The end of the tool's processing. 38 | ERROR (str): An error message. 39 | """ 40 | 41 | AGENT_START = "agent_start" 42 | AGENT_END = "agent_end" 43 | LLM_START = "llm_start" 44 | LLM_END = "llm_end" 45 | LLM_TEXT = "llm_text" 46 | ACCEPT_REQUEST = "accept_request" 47 | TOOL_START = "tool_start" 48 | TOOL_END = "tool_end" 49 | ERROR = "error" 50 | 51 | def __str__(self): 52 | return self.value 53 | 54 | 55 | class AgentMessage: 56 | """ 57 | Represents a message sent by the agent. 58 | 59 | Attributes: 60 | type (AgentMessageType): The type of the message. 61 | content (Optional[str]): The content of the message. 62 | tool_name (Optional[str]): The name of the tool. 63 | tool_input (Optional[str]): The input to the tool. 64 | """ 65 | 66 | def __init__( 67 | self, 68 | type: AgentMessageType, 69 | content: Optional[str] = None, 70 | tool_name: Optional[str] = None, 71 | tool_input: Optional[str] = None, 72 | ): 73 | self.type = type 74 | self.content = content 75 | self.tool_input = tool_input 76 | self.tool_name = tool_name 77 | 78 | def to_json(self) -> Dict[str, str]: 79 | """ 80 | Convert the AgentMessage instance to a JSON-serializable dictionary. 81 | 82 | Returns: 83 | Dict[str, str]: The JSON-serializable dictionary representing the AgentMessage instance. 84 | """ 85 | return {k: str(v) for k, v in self.__dict__.items() if v is not None} 86 | 87 | 88 | class UserMessage: 89 | """ 90 | Represents a message sent by the user. 91 | 92 | Attributes: 93 | type (UserMessageType): The type of the user message. 94 | content (Optional[str]): The content of the message. 95 | """ 96 | 97 | def __init__(self, input_json: Dict[str, str]): 98 | if "type" not in input_json: 99 | logger.error(f"Missing 'type' key in input_json: {input_json}") 100 | raise ValueError("Missing 'type' key in input_json") 101 | self.type = input_json["type"] 102 | if "content" in input_json: 103 | self.content = input_json["content"] 104 | 105 | def to_json(self) -> Dict[str, str]: 106 | """ 107 | Convert the UserMessage instance to a JSON-serializable dictionary. 108 | 109 | Returns: 110 | Dict[str, str]: The JSON-serializable dictionary representing the UserMessage instance. 111 | """ 112 | return {k: str(v) for k, v in self.__dict__.items() if v is not None} 113 | -------------------------------------------------------------------------------- /mint_agent/agent_graph/nodes/history_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any 3 | 4 | from langchain_core.messages import HumanMessage, RemoveMessage 5 | from loguru import logger 6 | 7 | from mint_agent.agent_state.state import GraphState, HistoryManagementType 8 | from mint_agent.llm.ChatFactory import ChatFactory 9 | from mint_agent.prompts.PromptController import PromptController 10 | from mint_agent.utils.errors import AgentError 11 | 12 | 13 | async def prepare_summary(messages: list[Any], state: dict[str, Any]) -> str: 14 | model = state["model_name"] 15 | provider = state["provider"] 16 | prev_summary = state["conversation_summary"] 17 | 18 | summary_prompt = PromptController.get_summary_prompt(prev_summary) 19 | messages_to_summarize = [ 20 | *messages, 21 | HumanMessage(content=summary_prompt), 22 | ] 23 | try: 24 | llm_model = ChatFactory.get_model_controller(provider, model) 25 | summary = llm_model.get_summary(messages_to_summarize) 26 | except Exception as e: 27 | raise AgentError("Failed to call LLM to summarize conversation") from e 28 | 29 | if isinstance(summary.content, str): 30 | return summary.content 31 | else: 32 | return summary.content[-1]["text"] 33 | 34 | 35 | def clear_message_history(messages: list[Any]) -> tuple[list[Any], list[Any]]: 36 | messages = messages[:-1] 37 | messages_to_delete = [] 38 | messages_to_summarize = [] 39 | 40 | for message in messages: 41 | messages_to_delete.append(RemoveMessage(id=message.id)) 42 | messages_to_summarize.append(message) 43 | 44 | return messages_to_delete, messages_to_summarize 45 | 46 | 47 | def history_manager(state: GraphState) -> GraphState: 48 | messages = state["messages"] 49 | history_config = state["history_config"] 50 | 51 | new_messages = [] 52 | messages_to_summarize = [] 53 | 54 | match history_config["management_type"]: 55 | case HistoryManagementType.KEEP_N_MESSAGES.value: 56 | if len(messages) > history_config["number_of_messages"]: 57 | num_of_messages_to_delete = ( 58 | len(messages) - history_config["number_of_messages"] 59 | ) 60 | while ( 61 | messages[num_of_messages_to_delete].type != "human" 62 | and num_of_messages_to_delete > 0 63 | ): 64 | num_of_messages_to_delete -= 1 65 | for i in range(0, num_of_messages_to_delete): 66 | new_messages.append(RemoveMessage(id=messages[i].id)) 67 | 68 | case HistoryManagementType.KEEP_N_TOKENS.value: 69 | if state["history_token_count"] > history_config["number_of_tokens"]: 70 | new_messages.append(RemoveMessage(id=messages[0].id)) 71 | i = 1 72 | while messages[i].type != "human" and i < len(messages): 73 | new_messages.append(RemoveMessage(id=messages[i].id)) 74 | i += 1 75 | 76 | case HistoryManagementType.SUMMARIZE_N_MESSAGES.value: 77 | if len(messages) > history_config["number_of_messages"]: 78 | new_messages, messages_to_summarize = clear_message_history(messages) 79 | 80 | case HistoryManagementType.SUMMARIZE_N_TOKENS.value: 81 | if state["history_token_count"] > history_config["number_of_tokens"]: 82 | new_messages, messages_to_summarize = clear_message_history(messages) 83 | 84 | case HistoryManagementType.NONE.value: 85 | new_messages = messages 86 | 87 | case _: 88 | logger.error(f"Invalid history type {history_config['type']}") 89 | raise ValueError(f"Invalid history type {history_config['type']}") 90 | 91 | if messages_to_summarize: 92 | summary = asyncio.run(prepare_summary(messages_to_summarize, state)) 93 | return { 94 | "messages": new_messages, 95 | "conversation_summary": summary, 96 | } 97 | 98 | return {"messages": new_messages} 99 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "mint_agent" 3 | version = "0.1.0" 4 | package-mode = false 5 | 6 | [tool.poetry.dependencies] 7 | python = ">=3.12,<3.13" 8 | aiohttp = "3.9.5" 9 | aiosignal = "1.3.1" 10 | annotated-types = "0.7.0" 11 | anthropic = "0.34.1" 12 | anyio = "4.4.0" 13 | asttokens = "2.4.1" 14 | asyncio = "3.4.3" 15 | attrs = "23.2.0" 16 | backcall = "0.2.0" 17 | beautifulsoup4 = "4.12.3" 18 | bleach = "6.1.0" 19 | certifi = "2024.7.4" 20 | charset-normalizer = "3.3.2" 21 | click = "8.1.7" 22 | colorama = "0.4.6" 23 | dataclasses-json = "0.6.7" 24 | decorator = "5.1.1" 25 | defusedxml = "0.7.1" 26 | distro = "1.9.0" 27 | dnspython = "2.6.1" 28 | docopt = "0.6.2" 29 | email-validator = "2.2.0" 30 | executing = "2.0.1" 31 | fastapi = "0.112.2" 32 | fastapi-cli = "0.0.5" 33 | fastjsonschema = "2.20.0" 34 | filelock = "3.15.4" 35 | frozenlist = "1.4.1" 36 | fsspec = "2024.6.1" 37 | greenlet = "3.0.3" 38 | h11 = "0.14.0" 39 | httpcore = "1.0.5" 40 | httptools = "0.6.1" 41 | httpx = "0.27.0" 42 | huggingface-hub = "0.23.5" 43 | idna = "3.7" 44 | inquirerpy = "0.3.4" 45 | ipython = "8.12.3" 46 | jedi = "0.19.1" 47 | jinja2 = "3.1.4" 48 | jiter = "0.5.0" 49 | jsonpatch = "1.33" 50 | jsonpointer = "3.0.0" 51 | jsonschema = "4.23.0" 52 | jsonschema-specifications = "2023.12.1" 53 | jupyter-client = "8.6.2" 54 | jupyter-core = "5.7.2" 55 | jupyterlab-pygments = "0.3.0" 56 | langchain = "0.2.14" 57 | langchain-anthropic = "0.1.23" 58 | langchain-community = "0.2.12" 59 | langchain-core = "0.2.35" 60 | langchain-openai = "0.1.22" 61 | langchain-text-splitters = "0.2.2" 62 | langgraph = "0.2.14" 63 | langgraph-checkpoint = "1.0.6" 64 | langsmith = "0.1.88" 65 | loguru = "0.7.2" 66 | markdown-it-py = "3.0.0" 67 | markupsafe = "2.1.5" 68 | marshmallow = "3.21.3" 69 | matplotlib-inline = "0.1.7" 70 | mdurl = "0.1.2" 71 | mistune = "3.0.2" 72 | motor = "3.5.1" 73 | multidict = "6.0.5" 74 | mypy-extensions = "1.0.0" 75 | mysql-connector-python = "9.0.0" 76 | nbclient = "0.10.0" 77 | nbconvert = "7.16.4" 78 | nbformat = "5.10.4" 79 | numpy = "1.26.4" 80 | oauthlib = "3.2.2" 81 | openai = "1.41.0" 82 | orjson = "3.10.6" 83 | packaging = "24.1" 84 | pandocfilters = "1.5.1" 85 | parso = "0.8.4" 86 | pfzy = "0.3.4" 87 | pickleshare = "0.7.5" 88 | pipreqs = "0.5.0" 89 | platformdirs = "4.2.2" 90 | prompt-toolkit = "3.0.47" 91 | pure-eval = "0.2.2" 92 | pydantic = "2.8.2" 93 | pydantic-core = "2.20.1" 94 | pygments = "2.18.0" 95 | pymongo = "4.8.0" 96 | python-dateutil = "2.9.0.post0" 97 | python-dotenv = "1.0.1" 98 | python-multipart = "0.0.9" 99 | pywin32 = {version = "306", markers = "platform_system == \"Windows\""} 100 | pyyaml = "6.0.1" 101 | pyzmq = "26.1.0" 102 | referencing = "0.35.1" 103 | regex = "2024.7.24" 104 | requests = "2.32.3" 105 | requests-oauthlib = "2.0.0" 106 | rich = "13.7.1" 107 | rpds-py = "0.19.1" 108 | shellingham = "1.5.4" 109 | six = "1.16.0" 110 | sniffio = "1.3.1" 111 | soupsieve = "2.5" 112 | sqlalchemy = "2.0.31" 113 | stack-data = "0.6.3" 114 | starlette = "0.37.2" 115 | tenacity = "8.5.0" 116 | tiktoken = "0.7.0" 117 | tinycss2 = "1.3.0" 118 | tokenizers = "0.19.1" 119 | tornado = "6.4.1" 120 | tqdm = "4.66.4" 121 | traitlets = "5.14.3" 122 | typer = "0.12.3" 123 | typing-inspect = "0.9.0" 124 | typing-extensions = "4.12.2" 125 | urllib3 = "2.2.2" 126 | uvicorn = "0.30.3" 127 | watchfiles = "0.22.0" 128 | wcwidth = "0.2.13" 129 | webencodings = "0.5.1" 130 | websockets = "13.0" 131 | win32-setctime = {version = "1.1.0", markers = "platform_system == \"Windows\""} 132 | yarg = "0.1.9" 133 | yarl = "1.9.4" 134 | cryptography = "^43.0.3" 135 | inquirer = "^3.4.0" 136 | termcolor = "^2.5.0" 137 | 138 | 139 | [build-system] 140 | requires = ["poetry-core"] 141 | build-backend = "poetry.core.masonry.api" 142 | 143 | [tool.poetry.scripts] 144 | dev = "mint_agent.server:dev" 145 | prod = "mint_agent.server:prod" 146 | test_chat = "mint_agent.server:test_chat" 147 | generate_credentials = "mint_agent.utils.generate_credentials:generate_credentials" 148 | generate_key = "mint_agent.utils.generate_credentials:generate_encryption_key" 149 | -------------------------------------------------------------------------------- /mint_agent/tools/ToolController.py: -------------------------------------------------------------------------------- 1 | from langchain_core.tools import ToolException 2 | from loguru import logger 3 | 4 | from mint_agent.tools.AvailabilityTool import AvailabilityTool 5 | from mint_agent.tools.CalendarTool import CalendarTool 6 | from mint_agent.tools.MintHCM.CreateMeeting import MintCreateMeetingTool 7 | from mint_agent.tools.MintHCM.CreateRecord import MintCreateRecordTool 8 | from mint_agent.tools.MintHCM.CreateRelationships import MintCreateRelTool 9 | from mint_agent.tools.MintHCM.DeleteRecord import MintDeleteRecordTool 10 | from mint_agent.tools.MintHCM.DeleteRelationships import MintDeleteRelTool 11 | from mint_agent.tools.MintHCM.GetModuleFields import MintGetModuleFieldsTool 12 | from mint_agent.tools.MintHCM.GetModuleNames import MintGetModuleNamesTool 13 | from mint_agent.tools.MintHCM.GetRelationships import MintGetRelTool 14 | from mint_agent.tools.MintHCM.GetUsers import MintGetUsersTool 15 | from mint_agent.tools.MintHCM.Search import MintSearchTool 16 | from mint_agent.tools.MintHCM.UpdateFields import MintUpdateFieldsTool 17 | 18 | 19 | def _handle_tool_error(error: ToolException) -> str: 20 | # find if what was returned contains phrase "Module ... does not exist" 21 | if "does not exist" in error.args[0]: 22 | logger.error(f"Agent was trying to use module, which does not exist. {error}") 23 | return f"Module Error: {error} . Try to use MintGetModuleNamesTool to get list of available modules." 24 | else: 25 | logger.error(f"The following error occurred during tool execution: {error}") 26 | return ( 27 | "The following errors occurred during tool execution:" 28 | + error.args[0] 29 | + "Please try another tool." 30 | ) 31 | 32 | 33 | class ToolController: 34 | available_tools = { 35 | "MintGetModuleNamesTool": MintGetModuleNamesTool( 36 | handle_tool_error=_handle_tool_error 37 | ), 38 | "MintGetModuleFieldsTool": MintGetModuleFieldsTool( 39 | handle_tool_error=_handle_tool_error 40 | ), 41 | "MintSearchTool": MintSearchTool(handle_tool_error=_handle_tool_error), 42 | "MintCreateRecordTool": MintCreateRecordTool( 43 | handle_tool_error=_handle_tool_error 44 | ), 45 | "MintCreateMeetingTool": MintCreateMeetingTool( 46 | handle_tool_error=_handle_tool_error 47 | ), 48 | "MintGetUsersTool": MintGetUsersTool(handle_tool_error=_handle_tool_error), 49 | "UpdateFieldsTool": MintUpdateFieldsTool(handle_tool_error=_handle_tool_error), 50 | "MintCreateRelTool": MintCreateRelTool(handle_tool_error=_handle_tool_error), 51 | "MintDeleteRecordTool": MintDeleteRecordTool( 52 | handle_tool_error=_handle_tool_error 53 | ), 54 | "MintDeleteRelTool": MintDeleteRelTool(handle_tool_error=_handle_tool_error), 55 | "MintGetRelTool": MintGetRelTool(handle_tool_error=_handle_tool_error), 56 | "CalendarTool": CalendarTool(name="CalendarTool"), 57 | "AvailabilityTool": AvailabilityTool(), 58 | } 59 | 60 | default_tools = [ 61 | "MintGetModuleNamesTool", 62 | "MintGetModuleFieldsTool", 63 | "MintSearchTool", 64 | "MintCreateRecordTool", 65 | "MintCreateMeetingTool", 66 | "MintGetUsersTool", 67 | "UpdateFieldsTool", 68 | "MintCreateRelTool", 69 | "MintDeleteRecordTool", 70 | "MintDeleteRelTool", 71 | "MintGetRelTool", 72 | "CalendarTool", 73 | "AvailabilityTool", 74 | ] 75 | 76 | safe_tools = [ 77 | "CalendarTool", 78 | ] 79 | 80 | @staticmethod 81 | def get_available_tools(): 82 | return ToolController.available_tools 83 | 84 | @staticmethod 85 | def get_default_tools(): 86 | return ToolController.default_tools 87 | 88 | @staticmethod 89 | def get_safe_tools(): 90 | return ToolController.safe_tools 91 | 92 | @staticmethod 93 | def get_tools(): 94 | available_tools = ToolController.get_available_tools() 95 | default_tools = ToolController.get_default_tools() 96 | return [available_tools[tool] for tool in default_tools] 97 | -------------------------------------------------------------------------------- /mint_agent/tools/AvailabilityTool.py: -------------------------------------------------------------------------------- 1 | import os 2 | from enum import Enum 3 | from typing import Type 4 | 5 | import mysql.connector 6 | from dotenv import load_dotenv 7 | from langchain.pydantic_v1 import BaseModel, Field 8 | from langchain.tools import BaseTool 9 | from langchain_core.runnables.config import RunnableConfig 10 | from mysql.connector import Error 11 | 12 | load_dotenv() 13 | 14 | 15 | class ModuleType(str, Enum): 16 | MEETINGS = "meetings" 17 | CALLS = "calls" 18 | 19 | 20 | class AvailabilityInput(BaseModel): 21 | start_date: str = Field(description="Start date of the period in YYYY-MM-DD format") 22 | end_date: str = Field(description="End date of the period in YYYY-MM-DD format") 23 | modules: list[ModuleType] = Field( 24 | description="List of modules to check availability for, can include 'meetings' and/or 'calls'" 25 | ) 26 | 27 | 28 | class AvailabilityTool(BaseTool): 29 | name = "AvailabilityTool" 30 | description = """ 31 | Useful when you want to check the availability of a person. This tool returns information about times when user is not available due to meetings and/or calls. 32 | """ 33 | args_schema: Type[BaseModel] = AvailabilityInput 34 | 35 | def _run( 36 | self, 37 | config: RunnableConfig, 38 | start_date: str, 39 | end_date: str, 40 | modules: list[ModuleType], 41 | ) -> str: 42 | """Use the tool.""" 43 | 44 | try: 45 | connection = mysql.connector.connect( 46 | host=os.environ.get("MINTDB_URI"), 47 | user=os.environ.get("MINTDB_USER"), 48 | password=os.environ.get("MINTDB_PASS"), 49 | port=os.environ.get("MINTDB_PORT"), 50 | database=os.environ.get("MINTDB_DATABASE_NAME"), 51 | ) 52 | except Error: 53 | return "Couldn't connect to the database. can't get the availability" 54 | 55 | meetings, calls = None, None 56 | mint_user_id = config.get("configurable", {}).get("mint_user_id") 57 | 58 | start_date_with_time = start_date + " 00:00:00" 59 | end_date_with_time = end_date + " 23:59:59" 60 | 61 | try: 62 | cursor = connection.cursor() 63 | if "meetings" in modules: 64 | cursor.execute( 65 | """SELECT name, date_start, date_end 66 | FROM meetings 67 | WHERE id IN (SELECT meeting_id FROM meetings_users where user_id = %s) 68 | AND date_start >= %s 69 | AND date_end <= %s""", 70 | (mint_user_id, start_date_with_time, end_date_with_time), 71 | ) 72 | meetings = cursor.fetchall() 73 | 74 | if "calls" in modules: 75 | cursor.execute( 76 | """SELECT name, duration_hours, duration_minutes, date_start, date_end 77 | FROM calls 78 | WHERE id IN (SELECT call_id FROM calls_users WHERE user_id = %s) 79 | AND date_start >= %s 80 | AND date_end <= %s""", 81 | (mint_user_id, start_date_with_time, end_date_with_time), 82 | ) 83 | calls = cursor.fetchall() 84 | cursor.close() 85 | except Error as e: 86 | return f"Error while fetching data from database: {e}" 87 | 88 | if not meetings and not calls: 89 | return "No meetings or calls found in the given period" 90 | 91 | meetings_output = [] 92 | for meeting in meetings: 93 | meetings_output.append( 94 | f"{meeting[0]}: {meeting[1].strftime('%Y-%m-%d %H:%M')} - {meeting[2].strftime('%Y-%m-%d %H:%M')}" 95 | ) 96 | 97 | calls_output = [] 98 | for call in calls: 99 | calls_output.append( 100 | f"{call[0]}: {call[3].strftime('%Y-%m-%d %H:%M')} - {call[4].strftime('%Y-%m-%d %H:%M')}" 101 | ) 102 | 103 | if not meetings: 104 | return f"Calls: {calls_output}" 105 | if not calls: 106 | return f"Meetings: {meetings_output}" 107 | 108 | return f"Meetings: {meetings_output}. Calls: {calls_output}." 109 | -------------------------------------------------------------------------------- /mint_agent/utils/AgentLogger.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import traceback 5 | from typing import Optional 6 | 7 | from dotenv import load_dotenv 8 | from loguru import logger 9 | 10 | from mint_agent.agent_state.state import GraphState 11 | 12 | INDENT = " " 13 | 14 | load_dotenv() 15 | log_level = os.getenv("LOG_LEVEL") 16 | log_file = os.getenv("LOG_FILE") 17 | log_to_console = os.getenv("LOG_TO_CONSOLE") 18 | log_coloring = os.getenv("LOG_COLORING") 19 | 20 | 21 | def configure_logging(): 22 | logger.remove(0) 23 | logger.add(log_file, level=log_level) 24 | if log_to_console == "TRUE": 25 | logger.add(sys.stdout, level=log_level) 26 | 27 | 28 | class AgentLogger: 29 | user_id: str 30 | chat_id: str 31 | ip_addr: str 32 | start_time: float 33 | end_time: float 34 | start_state: str 35 | end_state: str 36 | error: str 37 | usage_data: Optional[dict] 38 | 39 | def __init__(self, user_id: str, chat_id: str, ip_addr: str) -> None: 40 | self.user_id = user_id 41 | self.chat_id = chat_id 42 | self.ip_addr = ip_addr 43 | self.start_time = 0 44 | self.end_time = 0 45 | self.start_state = None 46 | self.end_state = None 47 | self.error = None 48 | self.usage_data = None 49 | 50 | def start(self, state: GraphState) -> None: 51 | self.start_time = time.time_ns() 52 | self.start_state = self._format_state(state) 53 | 54 | def end(self, state: GraphState) -> None: 55 | self.end_state = self._format_state(state) 56 | self.end_time = time.time_ns() 57 | self._save_log() 58 | self.clear() 59 | 60 | def end_error(self, state: GraphState, error: Exception) -> None: 61 | self.end_state = self._format_state(state) 62 | self.end_time = time.time_ns() 63 | self.error = error 64 | self._save_log(error=True) 65 | self.clear() 66 | 67 | def clear(self) -> None: 68 | self.start_time = 0 69 | self.end_time = 0 70 | self.start_state = None 71 | self.end_state = None 72 | self.error = None 73 | self.usage_data = None 74 | 75 | def set_usage_data(self, usage_data: dict) -> None: 76 | self.usage_data = usage_data 77 | 78 | def _save_log(self, error: bool = False) -> None: 79 | execution_time_ms = (self.end_time - self.start_time) / 1e6 80 | log_message = ( 81 | f"Mint agent interaction IP: {self.ip_addr}, user_id: {self.user_id}, chat_id: {self.chat_id}\n" 82 | f"{INDENT}Start state: {self.start_state}\n" 83 | f"{INDENT}End state: {self.end_state}\n" 84 | f"{INDENT}Execution time: {execution_time_ms}ms" 85 | ) 86 | 87 | if error: 88 | log_message += f"\n{INDENT}Error:\n{traceback.format_exc()}" 89 | logger.opt(colors=True if log_coloring == "TRUE" else False).error( 90 | log_message 91 | ) 92 | else: 93 | logger.opt(colors=True if log_coloring == "TRUE" else False).debug( 94 | log_message 95 | ) 96 | 97 | def _format_history_config(self, history_config: dict) -> str: 98 | return "\n" + "\n".join( 99 | f"{INDENT * 3}{key}: {value}" for key, value in history_config.items() 100 | ) 101 | 102 | def _format_messages(self, messages: list) -> str: 103 | message_map = { 104 | "system": "system", 105 | "human": "human", 106 | "ai": "llm", 107 | "tool": "tool", 108 | } 109 | 110 | messages_string = "" 111 | for message in messages: 112 | message_type = message_map.get(message.type, message.type) 113 | messages_string += f"\n{INDENT * 6}-> {message_type}: {message.content}" 114 | return messages_string 115 | 116 | def _format_usage_data(self) -> str: 117 | if self.usage_data: 118 | return ( 119 | "\n" 120 | f"{INDENT * 3}input tokens: {self.usage_data['tokens']['input_tokens']}\n" 121 | f"{INDENT * 3}output tokens: {self.usage_data['tokens']['output_tokens']}" 122 | ) 123 | return "" 124 | 125 | def _format_state(self, state: dict) -> str: 126 | history_config_string = self._format_history_config(state["history_config"]) 127 | 128 | messages_string = self._format_messages(state["messages"]) 129 | 130 | usage_data_string = self._format_usage_data() 131 | log_string = f""" 132 | user: {state["user"]} 133 | llm model name: {state["model_name"]} 134 | safe_tools: {state["safe_tools"]} 135 | tool_accept: {state["tool_accept"]} 136 | history_config: {history_config_string} 137 | conversation_summary: {state["conversation_summary"]} 138 | system_prompt: {state["system_prompt"]} 139 | history_token_count: {state["history_token_count"]} 140 | messages: {messages_string} 141 | usage_data: {usage_data_string}""" 142 | return log_string 143 | -------------------------------------------------------------------------------- /mint_agent/agent_state/state.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Annotated, Any, Optional, TypedDict 3 | 4 | from langgraph.graph import add_messages 5 | 6 | 7 | class HistoryManagementType(Enum): 8 | """ 9 | Enum representing different types of conversation history management strategies. 10 | 11 | Attributes: 12 | KEEP_N_MESSAGES: Keep only a fixed number of messages in memory (can vary to remain history integrity e.g. human message must be first message in the history). 13 | KEEP_N_TOKENS: Keep only messages that do not exceed a fixed number of tokens in memory. 14 | SUMMARIZE_N_MESSAGES: Create summary after reaching certain number of messages. 15 | SUMMARIZE_N_TOKENS: Create summary after reaching certain number of tokens. 16 | NONE: No history management, keep all messages in memory. 17 | """ 18 | 19 | KEEP_N_MESSAGES = "keep_n_messages" 20 | KEEP_N_TOKENS = "keen_n_tokens" 21 | SUMMARIZE_N_MESSAGES = "summarize_n_messages" 22 | SUMMARIZE_N_TOKENS = "summarize_n_tokens" 23 | NONE = "none" 24 | 25 | 26 | class HistoryManagement(TypedDict): 27 | """ 28 | TypedDict representing the configuration for history management. 29 | 30 | Attributes: 31 | management_type: The type of history management strategy. 32 | number_of_messages: The number of messages to keep or summarize for KEEP_N_MESSAGES and SUMMARIZE_N_MESSAGES (optional). 33 | number_of_tokens: The number of tokens to keep or summarize for KEEP_N_TOKENS and SUMMARIZE_N_TOKENS (optional). 34 | 35 | Raises: 36 | ValueError: If the management_type is invalid or if the number_of_messages 37 | or number_of_tokens is not a positive integer when required. 38 | """ 39 | 40 | def __init__( 41 | self, 42 | management_type: HistoryManagementType, 43 | number_of_messages: Optional[int] = None, 44 | number_of_tokens: Optional[int] = None, 45 | ): 46 | if management_type not in HistoryManagementType: 47 | raise ValueError( 48 | f"Invalid type {management_type}, must be one of {list(HistoryManagementType)}" 49 | ) 50 | if ( 51 | management_type 52 | in ( 53 | HistoryManagementType.KEEP_N_MESSAGES, 54 | HistoryManagementType.SUMMARIZE_N_MESSAGES, 55 | ) 56 | and number_of_messages is None 57 | or number_of_messages < 1 58 | ): 59 | raise ValueError("number_of_messages must be a positive integer") 60 | if ( 61 | management_type 62 | in ( 63 | HistoryManagementType.KEEP_N_TOKENS, 64 | HistoryManagementType.SUMMARIZE_N_TOKENS, 65 | ) 66 | and number_of_tokens is None 67 | or number_of_tokens < 1 68 | ): 69 | raise ValueError("number_of_tokens must be a positive integer") 70 | self.management_type = management_type 71 | self.number_of_messages = number_of_messages 72 | self.number_of_tokens = number_of_tokens 73 | 74 | def toJSON(self): 75 | return { 76 | "type": self.management_type.value, 77 | "number_of_messages": self.number_of_messages, 78 | "number_of_tokens": self.number_of_tokens, 79 | } 80 | 81 | 82 | class GraphState(TypedDict): 83 | """ 84 | TypedDict representing the state of a graph in the agent. 85 | 86 | Attributes: 87 | messages: A list of messages exchanged in the conversation with standard langgraph add_messages reducer. 88 | safe_tools: A list of safe tools available for the agent. 89 | tool_accept: A boolean indicating whether the tool is accepted - used for tool acceptance flow. 90 | user: The user associated with the graph state. 91 | model: The llm model used for the conversation. 92 | history_config: The configuration for history management. 93 | conversation_summary: A summary of the conversation. 94 | system_prompt: The system prompt used in the conversation. 95 | history_token_count: The count of tokens for messages in the current conversation history. 96 | """ 97 | 98 | messages: Annotated[list, add_messages] 99 | safe_tools: list[str] 100 | tool_accept: bool 101 | user: str 102 | provider: str 103 | model_name: str 104 | history_config: HistoryManagement 105 | conversation_summary: str 106 | system_prompt: str 107 | history_token_count: int 108 | tools: list[Any] 109 | 110 | def __init__( 111 | self, 112 | messages, 113 | safe_tools: list, 114 | user: str, 115 | provider: str, 116 | model_name: str, 117 | tool_accept: bool, 118 | history_config: HistoryManagement, 119 | system_prompt: str, 120 | tools: list[Any], 121 | history_token_count: int, 122 | conversation_summary: str = None, 123 | ): 124 | self.messages = messages 125 | self.safe_tools = safe_tools 126 | self.tool_accept = tool_accept 127 | self.user = user 128 | self.provider = provider 129 | self.model_name = model_name 130 | self.history_config = history_config 131 | self.conversation_summary = conversation_summary 132 | self.system_prompt = system_prompt 133 | self.history_token_count = history_token_count 134 | self.tools = tools 135 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | contact@evolpe.ai. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /mint_agent/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import traceback 3 | from typing import AsyncGenerator 4 | 5 | import uvicorn 6 | from fastapi import FastAPI, Query, WebSocket, WebSocketDisconnect 7 | from fastapi.responses import FileResponse 8 | from fastapi.websockets import WebSocketState 9 | from loguru import logger 10 | from motor.motor_asyncio import AsyncIOMotorClient 11 | 12 | from mint_agent.agent_api.CredentialManager import CredentialManager 13 | from mint_agent.agent_api.messages import AgentMessage, AgentMessageType, UserMessage 14 | from mint_agent.AgentMint import AgentMint 15 | from mint_agent.database.db_utils import AgentDatabase 16 | from mint_agent.utils.AgentLogger import configure_logging 17 | from mint_agent.utils.errors import ServerError 18 | 19 | configure_logging() 20 | 21 | http_chat = FastAPI() 22 | api = FastAPI() 23 | credential_manager = CredentialManager() 24 | 25 | 26 | async def call_agent( 27 | agent: AgentMint, message: str 28 | ) -> AsyncGenerator[AgentMessage, None]: 29 | """ 30 | Calls the agent with the given message and yields the response 31 | 32 | Args: 33 | agent: AgentMint object 34 | message: UserMessage object 35 | 36 | Yields: 37 | AgentMessage object 38 | """ 39 | async for message in agent.invoke(message): 40 | yield message 41 | 42 | 43 | class ConnectionManager: 44 | """ 45 | Manages active connections with the agent. 46 | """ 47 | 48 | def __init__(self): 49 | self.active_connections: list[WebSocket] = [] 50 | 51 | async def connect(self, websocket: WebSocket, user_id: str, token: str) -> bool: 52 | """ 53 | Authenticates the user and establishes a connection with the agent. 54 | 55 | Args: 56 | websocket: WebSocket object 57 | user_id: str 58 | token: str 59 | 60 | Returns: 61 | bool: True if the connection is established, False otherwise 62 | """ 63 | if not credential_manager.authenticate_user(user_id, token): 64 | logger.warning(f"Failed to authenticate {websocket.client}") 65 | await websocket.accept() 66 | await websocket.send_json( 67 | AgentMessage( 68 | type=AgentMessageType.ERROR, content="Authentication failed" 69 | ).to_json() 70 | ) 71 | await websocket.close() 72 | return False 73 | try: 74 | await websocket.accept() 75 | self.active_connections.append(websocket) 76 | logger.debug(f"Connected with {websocket.client}") 77 | return True 78 | except Exception as e: 79 | logger.error(f"Connection failed with {websocket.client} due to {e}") 80 | return False 81 | 82 | async def disconnect(self, websocket: WebSocket) -> None: 83 | """ 84 | Disconnects the user from the agent API. 85 | """ 86 | try: 87 | self.active_connections.remove(websocket) 88 | logger.debug(f"Connection closed with: {websocket.client}") 89 | except ValueError as e: 90 | logger.warning( 91 | f"Attempted to disconnect with {websocket.client} that was not connected: {e}" 92 | ) 93 | 94 | async def send_message(self, message: str, websocket: WebSocket) -> None: 95 | """ 96 | Sends a message to the connected socket. 97 | """ 98 | if websocket.client_state == WebSocketState.CONNECTED: 99 | try: 100 | await websocket.send_json(message) 101 | except Exception as e: 102 | logger.error(f"Error sending message to {websocket.client}: {e}") 103 | raise 104 | 105 | 106 | manager = ConnectionManager() 107 | 108 | 109 | @http_chat.get("/") 110 | async def get(): 111 | """ 112 | Server endpoint for the test chat page. 113 | """ 114 | return FileResponse("mint_agent/utils/chat.html") 115 | 116 | 117 | @api.websocket("/{user_id}/{chat_id}/{token}") 118 | async def websocket_endpoint( 119 | websocket: WebSocket, 120 | user_id: str, 121 | chat_id: str, 122 | token: str, 123 | advanced: bool = Query(False), 124 | ): 125 | """ 126 | Server endpoint for the WebSocket connection. 127 | 128 | Args: 129 | websocket: WebSocket object 130 | user_id: str 131 | chat_id: str 132 | token: str 133 | advanced: bool 134 | 135 | Raises: 136 | ServerError: If an error occurs during the conversation 137 | """ 138 | connected = await manager.connect(websocket, user_id, token) 139 | if not connected: 140 | return 141 | try: 142 | agent_db = AgentDatabase( 143 | AsyncIOMotorClient(os.getenv("MONGO_URI")), 144 | os.getenv("MONGO_DB_NAME"), 145 | user_id, 146 | ) 147 | user_data = await agent_db.get(["mint_user_id"]) 148 | 149 | agent = AgentMint( 150 | user_id=user_id, 151 | mint_user_id=user_data.get("mint_user_id"), 152 | chat_id=chat_id, 153 | ip_addr=websocket.client.host, 154 | is_advanced=advanced, 155 | ) 156 | while True: 157 | incoming_message = await websocket.receive_json() 158 | user_input = UserMessage(incoming_message) 159 | message_type = user_input.to_json()["type"] 160 | match message_type: 161 | case "input": 162 | logger.debug( 163 | f"Received input message: '{user_input.content}' from {websocket.client}, user_id: {user_id}, chat_id: {chat_id}" 164 | ) 165 | case "tool_confirm": 166 | logger.debug( 167 | f"Received tool confirmation from {websocket.client}, user_id: {user_id}, chat_id: {chat_id}" 168 | ) 169 | case "tool_reject": 170 | logger.debug( 171 | f"Received tool rejection: {user_input.content}' from {websocket.client}, user_id: {user_id}, chat_id: {chat_id}" 172 | ) 173 | case _: 174 | raise ServerError(f"Invalid message type received: {message_type}") 175 | 176 | async for message in call_agent(agent, user_input): 177 | await manager.send_message(message, websocket) 178 | except WebSocketDisconnect: 179 | await manager.disconnect(websocket) 180 | except ServerError as e: 181 | logger.error(f"Server error: {websocket.client} {traceback.format_exc()}") 182 | message = AgentMessage(type=AgentMessageType.ERROR, content=e.message).to_json() 183 | await manager.send_message(message, websocket) 184 | await manager.disconnect(websocket) 185 | raise 186 | except Exception: 187 | message = AgentMessage( 188 | type=AgentMessageType.ERROR, content="Internal error occurred" 189 | ).to_json() 190 | await manager.send_message(message, websocket) 191 | await manager.disconnect(websocket) 192 | raise 193 | 194 | 195 | def test_chat(): 196 | uvicorn.run("mint_agent.server:http_chat", host="0.0.0.0", port=80) 197 | 198 | 199 | def dev(): 200 | host = os.getenv("API_IP") 201 | port = int(os.getenv("API_PORT")) 202 | uvicorn.run("mint_agent.server:api", host=host, port=port, reload=True) 203 | 204 | 205 | def prod(): 206 | host = os.getenv("API_IP") 207 | port = int(os.getenv("API_PORT")) 208 | uvicorn.run("mint_agent.server:api", host=host, port=port) 209 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/Search.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict, Optional, Type 3 | 4 | from langchain.callbacks.manager import CallbackManagerForToolRun 5 | from langchain_core.runnables.config import RunnableConfig 6 | from langchain_core.tools import BaseTool, ToolException 7 | from pydantic import BaseModel, Field 8 | 9 | from mint_agent.tools.MintHCM.BaseTool import MintBaseTool 10 | from mint_agent.tools.MintHCM.GetModuleFields import MintGetModuleFieldsTool 11 | from mint_agent.tools.MintHCM.GetModuleNames import MintGetModuleNamesTool 12 | from mint_agent.tools.MintHCM.SuiteAPI import Module 13 | 14 | 15 | class MintSearchInput(BaseModel): 16 | module_name: str = Field( 17 | ..., 18 | description="Name of the module in Mint in which the information is to be read", 19 | ) 20 | filters: str = Field( 21 | ..., 22 | description=""" 23 | JSON with filters to apply to the query. 24 | Example: { "filters": { 25 | 26 | "date_start": { "operator": ">", "value": "2022-01-01" }, 27 | "assigned_user_id": { "operator": "=", "value": "1" } 28 | } } 29 | ONLY available operators : =, <> , > , >=, < , <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN 30 | For operators IN, NOT IN set value as string with comma separated values. 31 | For operator BETWEEN set value as string with two values separated by comma. 32 | When performing search on a datetime field to find records related to a specific date, use operator BETWEEN specific_date and specific_date + 1 day. 33 | Make sure to use operator BETWEEN for datetime fields when searching by datetime field to find records related to a specific date. 34 | Example, search for date_start on 2022-01-01: { "filters": { 35 | "date_start": { "operator": "BETWEEN", "value": "2022-01-01,2022-01-02" }, 36 | } } 37 | Remember that dates returned by MintHCM are in UTC timezone. 38 | If you need BETWEEN operator, you need to use two filters with > and < operators. 39 | """, 40 | ) 41 | operator: str = Field( 42 | ..., 43 | description="Operator to use to join all filter. Possible values: 'and','or'", 44 | ) 45 | fields: str = Field( 46 | ..., 47 | description="List of fields to retrieve from the module. Example: 'id,name,date_start,status'. Always use MintGetModuleFieldsTool to get list of fields available in the module. Do not use this tool without knowing the fields available in the module!", 48 | ) 49 | 50 | 51 | class MintSearchTool(BaseTool, MintBaseTool): 52 | name: str = "MintSearchTool" 53 | description: str = "Tool to retrieve list of records from MintHCM. Always use MintGetModuleFieldsTool to get list of fields available in the module. Do not use this tool without knowing the fields available in the module!" 54 | args_schema: Type[BaseModel] = MintSearchInput 55 | 56 | def _run( 57 | self, 58 | module_name: str, 59 | filters: str, 60 | operator: str, 61 | fields: str, 62 | config: RunnableConfig, 63 | run_manager: Optional[CallbackManagerForToolRun] = None, 64 | ) -> Dict[str, Any]: 65 | try: 66 | module_names_tool = MintGetModuleNamesTool() 67 | module_names = module_names_tool._run(config=config) 68 | 69 | if module_name not in module_names: 70 | raise ToolException( 71 | f"Module {module_name} does not exist. Try to use MintGetModuleNamesTool to get list of available modules." 72 | ) 73 | 74 | module_fields_tool = MintGetModuleFieldsTool() 75 | 76 | module_fields = module_fields_tool._run(module_name, config=config) 77 | # we need to check if the fields provided in the fields argument are in the module_fields 78 | # Example module_fields: 79 | # {'fields': {'id': {'dbType': 'id'}, 'name': {'dbType': 'name'}, 'date_entered': {'dbType': 'datetime'}} 80 | fields_array = fields.replace(" ", "").split(",") 81 | fieldss = module_fields["fields"] 82 | field_names = fieldss.keys() 83 | for field in fields_array: 84 | if field and field not in field_names: 85 | # print(f"Field {field} is not available in the module {module_name}. Use MintGetModuleFieldsTool to get list of fields available in the module.") 86 | # print(field_names) 87 | raise ToolException( 88 | f"Field {field} is not available in the module {module_name}. Use MintGetModuleFieldsTool to get list of fields available in the module." 89 | ) 90 | 91 | suitecrm = self.get_connection(config) 92 | module = Module(suitecrm, module_name) 93 | 94 | # print(f"Module: {module_name}, filters: {filters}, fields: {fields}") 95 | 96 | filters_array = json.loads(filters) 97 | fields_array = fields.replace(" ", "").split(",") 98 | 99 | # print(f"Filters: {filters_array}") 100 | # print(f"Fields: {fields_array}") 101 | 102 | if operator not in ["and", "or"]: 103 | operator = "and" 104 | operators = { 105 | "=": "EQ", 106 | "<>": "NEQ", 107 | ">": "GT", 108 | ">=": "GTE", 109 | "<": "LT", 110 | "<=": "LTE", 111 | } 112 | 113 | if "filters" in filters_array: 114 | filter_list_filters = filters_array["filters"] 115 | 116 | print(f"filter_list_filters: {filter_list_filters}") 117 | # filter_str = "" 118 | # for f in filter_list_filters: 119 | # print(f'fff: {f}') 120 | # for key in f: 121 | # filter_str += f"{key} {f[key]['operator']} '{f[key]['value']}' AND " 122 | 123 | if filter_list_filters: 124 | # we need to check if the fields provided in the filters are in the module_fields 125 | for field, value in filter_list_filters.items(): 126 | if field and field not in field_names: 127 | # print(f"Field {field} is not available in the module {module_name}. Use MintGetModuleFieldsTool to get list of fields available in the module.") 128 | print(module_fields) 129 | 130 | raise ToolException( 131 | f"Field {field} is not available in the module {module_name}. Use MintGetModuleFieldsTool to get list of fields available in the module." 132 | ) 133 | 134 | # for field, value in filter_list_filters.items(): 135 | # print(f'fff: {field}, {value}') 136 | # if isinstance(value, dict): 137 | # filter_str = f'{filter_str}[{field}][{operators[value["operator"]]}]={value["value"]}and&' 138 | # else: 139 | # filter_str = f'{filter_str}[{field}][eq]={value}and&' 140 | # print(f'Filters string : {filter_str}') 141 | 142 | # filter_list_filters.operator = 'AND' 143 | response = module.get( 144 | fields=fields_array, 145 | sort=None, 146 | operator=operator, 147 | **filter_list_filters, 148 | ) 149 | else: 150 | response = module.get( 151 | fields=fields_array, sort=None, operator=operator, deleted="0" 152 | ) 153 | else: 154 | response = module.get( 155 | fields=fields_array, sort=None, operator=operator, deleted="0" 156 | ) 157 | print(f"response: {response}") 158 | data = response 159 | # copy rows from data to return_data, only the attributes 160 | return_data = [] 161 | for row in data: 162 | return_data.append({"id": row["id"], **row["attributes"]}) 163 | return {"data": return_data} 164 | except Exception as e: 165 | raise ToolException(f"Error: {e}") 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI-HR-Agent 2 | AI HR Agent is first free and open-source AI chatbot for HCM System (e.g. [MintHCM](https://github.com/minthcm/minthcm/)) created by [eVolpe.ai](https://evolpe.ai) 3 | 4 | You can use this chatbot to search for and change information in your MintHCM system. 5 | 6 | Designed to simplify and streamline your workflow, our AI agent allows you to use natural language to communicate directly with your MintHCM system. 7 | 8 | Accessible via a WebSocket API, our agent acts as an external server that utilizes MintHCM API and direct Mint database calls to get things done. And the best part? You interact with it using an intuitive chat widget inside MintHCM. 9 | 10 | ### What can it do? 11 | Our agent comes equipped with tools to help you perform a variety of tasks, including: 12 | * 📅 **Checking your schedule** - It can fetch information about your upcoming meetings and calls. 13 | * 💼 **Employee and candidate lookup** - Retrieving information about employees or candidates. 14 | * 📝 **Creating records** - From scheduling meetings or calls to adding new candidates and more. 15 | * 🔄 **Updating existing records** - Easily modify existing entries, like adding participants to existing meetings 16 | 17 | ### Human supervision for important actions 18 | A human in-the-loop mechanism allows for the user to oversee bot's tool usage. This means you can approve or decline the agent's actions before they're executed - giving you control while still benefiting from the automation. 19 | 20 | ## Limitations 21 | 22 | 1. **LLM non-determinism** Like any large language model, responses can vary and are not always deterministic. This leads to the fact that the agent doesn't always choose the correct tools, as it may try different approaches to reach a solution. 23 | 2. **LLM compatibility** While our agent is designed to work with various LLMs, currently we provide interfaces only for OpenAI and Anthropic models. 24 | 3. **Tool restrictions**: 25 | * The lack of user consent for utilizing a tool at the moment makes the agent unlikely to attempt using that tool in further conversation. In such cases, it is recommended to create a new chat with a clean history. 26 | * Some tools are now restricted to operating only on certain modules due to the ease of testing and to narrow down options for the LLM so it provides more reliable results. 27 | * Instead of asking the user for missing information when using a certain tool, the agent can fabricate some details (e.g. function arguments) 28 | 4. **Time handling** - Currently, the agent can't deal with database date formats. This means that when your prompt includes dates, times, or any requests related to specific hours, the results can be inaccurate. 29 | 5. **History management** - Token-based history management works as a rough approximation and should not be considered a reliable method for systems aiming to limit token usage. 30 | 31 | ## Agent Structure 32 | We are utilizing LangGraph to create the architecture of our agent. The following structure is a backbone of our solution: 33 |

34 | graph image 35 |

36 | 37 | 1. **gear_manager_node:** Currently responsible for setting the prompt in a new conversation. 38 | 2. **history_manager_node:** Depending on the selected history management method, it creates summaries and removes redundant messages. 39 | 3. **llm_node:** Responsible for communication with the chosen LLM API. 40 | 4. **tool_controller_node:** Verifies whether the tool selected by the LLM is on the list of safe tools. 41 | 5. **tool_node:** Executes the tool selected by the LLM. 42 | 43 | ## Available configuration 44 | 45 | ### Tools 46 | All tools are located in a `tools` directory. ToolController defines and manages the tools available to the agent. It provides three configurable lists: 47 | * `available_tools` - all tools defined in the system 48 | * `default_tools` - tools accessible to the agent by default 49 | * `safe_tools` - tools that do not require user acceptance 50 | 51 | List of tools: 52 | 1. `MintCreateMeetingTool` - Schedules a new meeting in the system. 53 | 2. `MintCreateRecordTool` - Creates a new record in the specified module. 54 | 3. `MintCreateRelTool` - Establishes a relationship in the meetings module. 55 | 4. `MintDeleteRecordTool` - Deletes a record from the specified module. 56 | 5. `MintDeleteRelTool` - Removes a relationship from the meetings module. 57 | 6. `MintGetModuleFieldsTool` - Retrieves the fields and their types for the specified module. 58 | 7. `MintGetModuleNamesTool` - Retrieves all available modules in the system. 59 | 8. `MintGetRelTool` - Checks if relationship exists in the meetings module. 60 | 9. `MintGetUsersTool` - Retrieves a list of users in the system, including details like phone number, address, email, position, and supervisor. 61 | 10. `MintSearchTool` - Retrieves a list of records from a specified module. 62 | 11. `MintUpdateFieldsTool` - Updates fields in a specified module. 63 | 12. `CalendarTool` - Retrieves today's date. 64 | 13. `AvailabilityTool` - Retrieves data about a user's planned activities (meetings and/or calls). 65 | 66 | 67 | ### Accessible modules 68 | Agent can get a list of modules that are available in Mint via the MintGetModuleNames tool. This tool has the option to configure a white and a black list of modules. When both lists are not used, the agent will, by default, have access to all modules. 69 | 70 | ### Prompts 71 | Changes to system prompts, as well as the prompts used during conversation history summarization, can be made in: `mint_agent/prompts/PromptController.py` 72 | 73 | Additionally, each tool may have its own specific fields and general description prompt within their respective files located in: `mint_agent/tools` 74 | 75 | ### History Management 76 | At the moment, there are 4 types of message history management available for LLMs: 2 based on the number of messages and 2 based on the number of tokens used. 77 | * Message-based methods: 78 | 1. `KEEP_N_MESSAGES` -> Keep only a fixed number of messages in memory (can vary to maintain history integrity, e.g. human message must be first message in the history). 79 | 2. `SUMMARIZE_N_MESSAGES` -> Create summary after reaching certain number of messages. 80 | * Token-based methods (currently support only Anthropic models): 81 | 1. `KEEP_N_TOKENS` -> Keep only messages that do not exceed a fixed number of tokens in memory. 82 | 2. `SUMMARIZE_N_TOKENS` -> Create summary after reaching certain number of tokens. 83 | 84 | ## Installation 85 | 86 | ### MintHCM 87 | 88 | 1. Set up the MintHCM instance by following the installation guide. 89 | 90 | ### Agent Server 91 | 92 | 1. Install Poetry following the installation guide. 93 | 94 | 2. Prepare mongoDB database server. Refer to the MongoDB installation guide for instructions. 95 | 96 | 3. Install all dependencies: 97 | ``` 98 | poetry install 99 | ``` 100 | 101 | 4. Copy `.env_example` as `.env` and fill in required fields 102 | 103 | 5. Generate encryption key with: 104 | ``` 105 | poetry run generate_key 106 | ``` 107 | 108 | 5. Setting up credentials: 109 | 1. Follow this instruction on how to get `client_id` and `secret` for your user. 110 | 111 | 1. Run script to add user credentials to agent database: 112 | ``` 113 | poetry run generate_credentials 114 | ``` 115 | 116 | * `user name`: The user_name used in MintHCM 117 | * `mint_user_id`: The user’s ID within the MintHCM system. 118 | * `client_id`: The API client ID for accessing MintHCM. 119 | * `client secret`: The API secret key associated with the client ID. 120 | 121 | 122 | 123 | 124 | ### MintHCM Agent package 125 | 126 | 1. **Prerequisites**: Ensure you have Node.js installed on your system. The supported Node.js versions range from v16 to v21. If you haven't installed Node.js yet, you can use the NodeSource installer to set it up. 127 | 128 | 2. Install mint_agent_package via Module Loader 129 | 130 | 3. Navigate to the `vue` directory: 131 | ``` 132 | cd /path/to/your/MintHCM/vue 133 | ``` 134 | 135 | 4. Install node modules: 136 | ``` 137 | npm install 138 | ``` 139 | 140 | 5. Run build script: 141 | ``` 142 | npm run build:repo 143 | ``` 144 | 145 | 6. Run Quick Repair 146 | 147 | 7. Clear cache 148 | 149 | 8. Open `api/constants/AiChat.php` and set service domain to your agent server: 150 | ``` 151 | ... 152 | 'service_domain' => 'agent.domain.example', 153 | ... 154 | ``` 155 | 156 | ## Running the Agent: 157 | 158 | 1. Run the agent in preferred mode: 159 | * Run Agent server (`dev` runs uvicorn with auto-reload enabled): 160 | ```sh 161 | poetry run dev # For development mode 162 | poetry run prod # For production mode 163 | ``` 164 | * (Optional) You can evaluate the agent outside the Mint environment by running a test chat (available at `localhost:80`, may require adjusting API address in `connectWebSocket` function in `utils/chat.html`) 165 | ```sh 166 | poetry run test_chat 167 | ``` 168 | 2. Start talking to Agent via chat widget available in MintHCM 169 | -------------------------------------------------------------------------------- /mint_agent/AgentMint.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from typing import AsyncGenerator 4 | 5 | from dotenv import load_dotenv 6 | from langchain_core.messages import HumanMessage, ToolMessage 7 | from loguru import logger 8 | from motor.motor_asyncio import AsyncIOMotorClient 9 | 10 | from mint_agent.agent_api.messages import ( 11 | AgentMessage, 12 | AgentMessageType, 13 | UserMessage, 14 | UserMessageType, 15 | ) 16 | from mint_agent.agent_graph.graph import compile_workflow, create_graph 17 | from mint_agent.agent_state.state import ( 18 | GraphState, 19 | HistoryManagement, 20 | HistoryManagementType, 21 | ) 22 | from mint_agent.database.db_utils import MongoDBUsageTracker 23 | from mint_agent.llm.ChatFactory import ProviderConfig 24 | from mint_agent.tools.ToolController import ToolController 25 | from mint_agent.utils.AgentLogger import AgentLogger 26 | 27 | load_dotenv() 28 | 29 | 30 | class AgentMint: 31 | """ 32 | The main agent class that handles the agent's workflow and interactions with the user. 33 | """ 34 | 35 | def __init__( 36 | self, 37 | user_id: str, 38 | mint_user_id: str, 39 | chat_id: str, 40 | ip_addr: str, 41 | is_advanced: bool, 42 | ) -> None: 43 | tools = ToolController.get_tools() 44 | self.state = None 45 | self.chat_id = chat_id 46 | self.user_id = user_id 47 | self.ip_addr = ip_addr 48 | self.is_advanced = is_advanced 49 | 50 | self.graph = create_graph(tools) 51 | self.app = compile_workflow(self.graph, user_id) 52 | 53 | self.usage_tracker = MongoDBUsageTracker( 54 | AsyncIOMotorClient(os.getenv("MONGO_URI")), 55 | os.getenv("MONGO_DB_NAME"), 56 | user_id, 57 | ) 58 | 59 | self.history_config = HistoryManagement( 60 | management_type=HistoryManagementType.KEEP_N_MESSAGES.value, 61 | number_of_messages=15, 62 | number_of_tokens=430, 63 | ) 64 | 65 | self.config = { 66 | "configurable": { 67 | "chat_id": chat_id, 68 | "user_id": user_id, 69 | "mint_user_id": mint_user_id, 70 | } 71 | } 72 | 73 | self.agent_logger = AgentLogger(self.user_id, self.chat_id, self.ip_addr) 74 | 75 | async def set_state(self) -> None: 76 | """ 77 | Set the agent's state based on the previous state stored in the database. 78 | """ 79 | try: 80 | prev_state = await self.app.aget_state(self.config) 81 | self.state = GraphState( 82 | messages=prev_state.values["messages"], 83 | user=self.user_id, 84 | provider=os.environ.get("LLM_PROVIDER", "ANTHROPIC"), 85 | model_name=os.environ.get("LLM_MODEL", "claude-3-haiku-20240307"), 86 | tools=ToolController.get_default_tools(), 87 | safe_tools=ToolController.get_safe_tools(), 88 | tool_accept=prev_state.values.get("tool_accept", False), 89 | history_config=self.history_config, 90 | conversation_summary=prev_state.values.get( 91 | "conversation_summary", None 92 | ), 93 | system_prompt=prev_state.values.get("system_prompt", None), 94 | history_token_count=prev_state.values.get("history_token_count", 0), 95 | ) 96 | except Exception as e: 97 | logger.error(f"Failed to get previous state: {e}") 98 | raise 99 | 100 | def visualize_graph(self) -> None: 101 | """ 102 | Visualize the agent's graph schema and save it as a PNG file. 103 | """ 104 | self.app.get_graph().draw_mermaid_png(output_file_path="utils/graph_schema.png") 105 | 106 | async def invoke(self, message: UserMessage) -> AsyncGenerator[str, None]: 107 | """ 108 | Invoke the agent with the given user message. 109 | 110 | Args: 111 | message (UserMessage): The message object containing the type and content of the user's message. 112 | 113 | Yields: 114 | str: The JSON representation of the agent message to be sent to the user. 115 | 116 | Raises: 117 | Exception: If an error occurs during the agent's execution 118 | """ 119 | await self.set_state() 120 | self.agent_logger.start(self.state) 121 | 122 | try: 123 | self.handle_message(message) 124 | yield AgentMessage(type=AgentMessageType.AGENT_START).to_json() 125 | 126 | async for event in self.app.astream_events( 127 | input=self.state, version="v2", config=self.config 128 | ): 129 | output = await self.handle_graph_event(event) 130 | if output is not None: 131 | yield output.to_json() 132 | yield AgentMessage(type=AgentMessageType.AGENT_END).to_json() 133 | except Exception as e: 134 | await self.set_state() 135 | self.agent_logger.end_error(self.state, e) 136 | raise 137 | 138 | await self.set_state() 139 | self.agent_logger.end(self.state) 140 | 141 | def handle_message(self, message: UserMessage) -> None: 142 | """ 143 | Handle incoming user messages and update the agent's state accordingly. 144 | 145 | Args: 146 | message (UserMessage): The message object containing the type and content of the user's message. 147 | 148 | Raises: 149 | ValueError: If the message type is unknown. 150 | """ 151 | match message.type: 152 | case UserMessageType.INPUT.value: 153 | self.state["messages"].append( 154 | HumanMessage(content=f"{message.content}") 155 | ) 156 | self.state["tool_accept"] = False 157 | case UserMessageType.TOOL_CONFIRM.value: 158 | self.state["tool_accept"] = True 159 | case UserMessageType.TOOL_REJECT.value: 160 | tool_call_message = self.state["messages"][-1].tool_calls[0] 161 | self.state["tool_accept"] = False 162 | self.state["messages"].append( 163 | ToolMessage( 164 | tool_call_id=tool_call_message["id"], 165 | content="Tool call rejected by the user.", 166 | ) 167 | ) 168 | self.state["messages"].append( 169 | HumanMessage( 170 | content=f"I rejected the use of the tool {tool_call_message["name"]} {f"because: {message.content}." if message.content else "and i don't want to provide a reason."}" 171 | ) 172 | ) 173 | case _: 174 | raise ValueError(f"Unknown message type: {message.type}") 175 | 176 | async def handle_graph_event(self, event: dict) -> AgentMessage | None: 177 | """ 178 | Handle various graph events and update the agent's state accordingly. 179 | 180 | Args: 181 | event (dict): The event data containing information about the event. 182 | agent_logger: The logger instance to log usage data. 183 | 184 | Returns: 185 | AgentMessage | None: The message to be sent based on the event type, or None if no message is to be sent. 186 | """ 187 | event_kind = event["event"] 188 | output = None 189 | match event_kind: 190 | case "on_chat_model_stream": 191 | if "silent" not in event["tags"]: 192 | content = event["data"]["chunk"].content 193 | if content: 194 | if isinstance(content, str): 195 | output = AgentMessage( 196 | type=AgentMessageType.LLM_TEXT, content=content 197 | ) 198 | elif content[-1]["type"] == "text": 199 | output = AgentMessage( 200 | type=AgentMessageType.LLM_TEXT, 201 | content=content[-1]["text"], 202 | ) 203 | case "on_chat_model_start": 204 | output = AgentMessage(type=AgentMessageType.LLM_START) 205 | case "on_chat_model_end": 206 | output = AgentMessage(type=AgentMessageType.LLM_END) 207 | returns_usage_data = ProviderConfig.get_param( 208 | self.state["provider"], "returns_usage_data" 209 | ) 210 | self.state["messages"].append(event["data"]["output"]) 211 | 212 | if returns_usage_data: 213 | usage_data = { 214 | "tokens": event["data"]["output"].usage_metadata, 215 | "timestamp": datetime.now(), 216 | } 217 | await self.usage_tracker.push_token_usage(usage_data) 218 | self.state["history_token_count"] = event["data"][ 219 | "output" 220 | ].usage_metadata["input_tokens"] 221 | self.agent_logger.set_usage_data(usage_data) 222 | case "on_tool_start": 223 | if self.is_advanced: 224 | output = AgentMessage( 225 | type=AgentMessageType.TOOL_START, 226 | tool_name=event["name"], 227 | tool_input=event["data"]["input"], 228 | ) 229 | case "on_tool_end": 230 | if self.is_advanced: 231 | output = AgentMessage( 232 | type=AgentMessageType.TOOL_END, 233 | ) 234 | case "on_custom_event": 235 | if event["name"] == "tool_accept": 236 | output = AgentMessage( 237 | type=AgentMessageType.ACCEPT_REQUEST, 238 | tool_input=event["data"]["params"], 239 | tool_name=event["data"]["tool"], 240 | ) 241 | 242 | return output 243 | -------------------------------------------------------------------------------- /mint_agent/database/db_utils.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from contextlib import AbstractContextManager 3 | from datetime import datetime, timedelta 4 | from types import TracebackType 5 | from typing import AsyncIterator, Optional, Sequence 6 | 7 | from langchain_core.runnables import RunnableConfig 8 | from langgraph.checkpoint.base import ( 9 | BaseCheckpointSaver, 10 | ChannelVersions, 11 | Checkpoint, 12 | CheckpointMetadata, 13 | CheckpointTuple, 14 | SerializerProtocol, 15 | ) 16 | from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer 17 | from loguru import logger 18 | from motor.motor_asyncio import AsyncIOMotorClient 19 | from typing_extensions import Self 20 | 21 | 22 | class JsonPlusSerializerCompat(JsonPlusSerializer): 23 | def loads(self, data: bytes) -> any: 24 | if data.startswith(b"\x80") and data.endswith(b"."): 25 | return pickle.loads(data) 26 | return super().loads(data) 27 | 28 | 29 | class MongoDBBase: 30 | client: AsyncIOMotorClient 31 | db_name: str 32 | collection_name: str 33 | 34 | def __init__( 35 | self, 36 | client: AsyncIOMotorClient, 37 | db_name: str, 38 | collection_name: str, 39 | ) -> None: 40 | self.client = client 41 | self.db_name = db_name 42 | self.collection_name = collection_name 43 | self.collection = client[db_name][collection_name] 44 | 45 | 46 | class MongoDBCheckpointSaver(BaseCheckpointSaver, AbstractContextManager, MongoDBBase): 47 | serde = JsonPlusSerializerCompat() 48 | 49 | client: AsyncIOMotorClient 50 | db_name: str 51 | collection_name: str 52 | 53 | def __init__( 54 | self, 55 | client: AsyncIOMotorClient, 56 | db_name: str, 57 | user: str, 58 | *, 59 | serde: Optional[SerializerProtocol] = None, 60 | ) -> None: 61 | super().__init__(serde=serde) 62 | MongoDBBase.__init__(self, client, db_name, f"{user}_chats") 63 | 64 | def __enter__(self) -> Self: 65 | return self 66 | 67 | def __exit__( 68 | self, 69 | __exc_type: Optional[type[BaseException]], 70 | __exc_value: Optional[BaseException], 71 | __traceback: Optional[TracebackType], 72 | ) -> Optional[bool]: 73 | return True 74 | 75 | async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]: 76 | user_id = config["configurable"]["user_id"] 77 | chat_id = config["configurable"]["chat_id"] 78 | checkpoint_id = config["configurable"].get("checkpoint_id") 79 | 80 | base_query = {"chat_id": chat_id} 81 | 82 | if checkpoint_id: 83 | base_query["checkpoint_id"] = checkpoint_id 84 | 85 | try: 86 | result = self.collection.find(base_query).sort("checkpoint_id", -1).limit(1) 87 | 88 | if await result.fetch_next: 89 | last_checkpoint = await result.next() 90 | else: 91 | return None 92 | 93 | return CheckpointTuple( 94 | { 95 | "configurable": { 96 | "chat_id": chat_id, 97 | "checkpoint_id": last_checkpoint["checkpoint_id"], 98 | "user_id": user_id, 99 | } 100 | }, 101 | self.serde.loads(last_checkpoint["checkpoint"]), 102 | self.serde.loads(last_checkpoint["metadata"]), 103 | ( 104 | { 105 | "configurable": { 106 | "chat_id": chat_id, 107 | "checkpoint_id": last_checkpoint["parent_checkpoint_id"], 108 | } 109 | } 110 | if last_checkpoint.get("parent_checkpoint_id") 111 | else None 112 | ), 113 | ) 114 | except Exception as e: 115 | logger.error(f"Error in aget_tuple function: {e}") 116 | 117 | async def alist( 118 | self, 119 | config: Optional[RunnableConfig], 120 | *, 121 | filters: Optional[dict[str, any]] = None, 122 | before: Optional[RunnableConfig] = None, 123 | limit: Optional[int] = None, 124 | ) -> AsyncIterator[CheckpointTuple]: 125 | query = {} 126 | if config is not None: 127 | query["chat_id"] = config["configurable"]["chat_id"] 128 | 129 | if filters: 130 | for key, value in filters.items(): 131 | query[f"metadata.{key}"] = value 132 | 133 | if before is not None: 134 | query["checkpoint_id"] = {"$lt": before["configurable"]["checkpoint_id"]} 135 | 136 | result = self.collection.find(query).sort("checkpoint_id", -1) 137 | 138 | if limit: 139 | result = result.limit(limit) 140 | 141 | try: 142 | async for doc in result: 143 | checkpoint = self.serde.loads_typed((doc["type"], doc["checkpoint"])) 144 | 145 | parent_config = None 146 | if "parent_checkpoint_id" in checkpoint: 147 | parent_config = { 148 | "configurable": { 149 | "chat_id": doc["chat_id"], 150 | "checkpoint_id": checkpoint["parent_checkpoint_id"], 151 | } 152 | } 153 | 154 | yield CheckpointTuple( 155 | { 156 | "configurable": { 157 | "chat_id": doc["chat_id"], 158 | "checkpoint_id": checkpoint["checkpoint_id"], 159 | } 160 | }, 161 | checkpoint, 162 | self.serde.loads(checkpoint["metadata"]), 163 | parent_config, 164 | ) 165 | except Exception as e: 166 | logger.error(f"Error in alist function: {e}") 167 | 168 | async def aput( 169 | self, 170 | config: RunnableConfig, 171 | checkpoint: Checkpoint, 172 | metadata: CheckpointMetadata, 173 | new_versions: ChannelVersions, 174 | ) -> RunnableConfig: 175 | user_id = config["configurable"]["user_id"] 176 | chat_id = config["configurable"]["chat_id"] 177 | checkpoint_id = config["configurable"].get("checkpoint_id") 178 | 179 | checkpoint_data = { 180 | "chat_id": chat_id, 181 | "checkpoint_id": checkpoint["id"], 182 | "checkpoint": self.serde.dumps(checkpoint), 183 | "metadata": self.serde.dumps(metadata), 184 | } 185 | 186 | if checkpoint_id: 187 | checkpoint_data["parent_checkpoint_id"] = checkpoint_id 188 | 189 | try: 190 | upsert_query = {"chat_id": chat_id, "checkpoint_id": checkpoint["id"]} 191 | 192 | await self.collection.update_one( 193 | upsert_query, {"$set": checkpoint_data}, upsert=True 194 | ) 195 | 196 | return { 197 | "configurable": { 198 | "chat_id": chat_id, 199 | "checkpoint_id": checkpoint["id"], 200 | "user_id": user_id, 201 | } 202 | } 203 | except Exception as e: 204 | logger.error(f"Error in aput function: {e}") 205 | 206 | async def aput_writes( 207 | self, 208 | config: RunnableConfig, 209 | writes: Sequence[tuple[str, any]], 210 | task_id: str, 211 | ) -> None: 212 | pass 213 | # TODO: Implement this function if needed, langgraph documentation is not clear on what this function should exactly do 214 | # user_id = config["configurable"]["user_id"] 215 | # chat_id = config["configurable"]["chat_id"] 216 | # checkpoint_id = config["configurable"]["checkpoint_id"] 217 | 218 | # try: 219 | # await self.collection.update_one( 220 | # { 221 | # "_id": user_id, 222 | # "chats.chat_id": chat_id, 223 | # "chats.checkpoints.checkpoint_id": checkpoint_id, 224 | # }, 225 | # { 226 | # "$push": { 227 | # "chats.$.checkpoints.$[checkpoint].writes": [ 228 | # { 229 | # "task_id": task_id, 230 | # "idx": idx, 231 | # "channel": channel, 232 | # "value": self.serde.dumps(value), 233 | # } 234 | # for idx, (channel, value) in enumerate(writes) 235 | # ] 236 | # } 237 | # }, 238 | # array_filters=[{"checkpoint.checkpoint_id": checkpoint_id}], 239 | # ) 240 | # except Exception as e: 241 | # logger.error(f"Error in aput_writes function: {e}") 242 | 243 | 244 | class MongoDBUsageTracker(MongoDBBase): 245 | def __init__( 246 | self, 247 | client: AsyncIOMotorClient, 248 | db_name: str, 249 | user: str, 250 | ) -> None: 251 | super().__init__(client, db_name, f"{user}_tokens") 252 | 253 | async def push_token_usage(self, usage_data: dict) -> None: 254 | try: 255 | await self.collection.insert_one(usage_data) 256 | except Exception as e: 257 | logger.error(f"Error while pushing token usage: {e}") 258 | 259 | async def get_token_usage(self, hours: int) -> int: 260 | time_period = datetime.now() - timedelta(hours=hours) 261 | 262 | pipeline = [ 263 | {"$match": {"timestamp": {"$gte": time_period}}}, 264 | { 265 | "$group": { 266 | "_id": None, 267 | "total_input_tokens": {"$sum": "$tokens.input_tokens"}, 268 | "total_output_tokens": {"$sum": "$tokens.output_tokens"}, 269 | "total_tokens": {"$sum": "$tokens.total_tokens"}, 270 | } 271 | }, 272 | ] 273 | 274 | try: 275 | result = await self.collection.aggregate(pipeline).to_list(1) 276 | 277 | if result: 278 | doc = result[0] 279 | return { 280 | "total_input_tokens": doc.get("total_input_tokens", 0), 281 | "total_output_tokens": doc.get("total_output_tokens", 0), 282 | "total_tokens": doc.get("total_tokens", 0), 283 | } 284 | else: 285 | return { 286 | "total_input_tokens": 0, 287 | "total_output_tokens": 0, 288 | "total_tokens": 0, 289 | } 290 | except Exception as e: 291 | logger.error(f"Error while getting token usage: {e}") 292 | 293 | 294 | class AgentDatabase(MongoDBBase): 295 | def __init__( 296 | self, 297 | client: AsyncIOMotorClient, 298 | db_name: str, 299 | user: str, 300 | ) -> None: 301 | super().__init__(client, db_name, user) 302 | 303 | async def get(self, field_names: list) -> dict: 304 | try: 305 | projection = {field: 1 for field in field_names} 306 | return await self.collection.find_one({}, projection=projection) 307 | except Exception as e: 308 | logger.error(f"Error while getting data from database: {e}") 309 | -------------------------------------------------------------------------------- /mint_agent/utils/chat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mint Agent Chat 7 | 227 | 228 | 229 |
230 |
231 |
232 |
233 | 234 | 235 |
236 |
237 | 238 | 239 |
240 |
241 | 242 | 243 |
244 |
245 |
246 | 247 | 248 |
249 |
250 | 251 | 252 | 253 |
254 |
255 |
Disconnected
256 |
257 |
258 |
259 | 260 | 261 |
262 |
263 | 264 | 265 |
266 |
267 | 439 | 440 | -------------------------------------------------------------------------------- /mint_agent/tools/MintHCM/SuiteAPI.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import json 3 | import math 4 | import uuid 5 | from urllib.parse import quote 6 | 7 | from oauthlib.oauth2 import ( 8 | BackendApplicationClient, 9 | InvalidClientError, 10 | TokenExpiredError, 11 | ) 12 | from oauthlib.oauth2.rfc6749.errors import CustomOAuth2Error 13 | from requests_oauthlib import OAuth2Session 14 | 15 | 16 | class SuiteCRM: 17 | def __init__( 18 | self, client_id: str, client_secret: str, url: str, logout_on_exit: bool = False 19 | ): 20 | self.baseurl = url 21 | self._client_id = client_id 22 | self._client_secret = client_secret 23 | self._logout_on_exit = logout_on_exit 24 | self._headers = ( 25 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " 26 | "(KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36" 27 | ) 28 | self._login() 29 | self._modules() 30 | 31 | def _modules(self): 32 | self.Accounts = Module(self, "Accounts") 33 | self.Bugs = Module(self, "Bugs") 34 | self.Calendar = Module(self, "Calendar") 35 | self.Calls = Module(self, "Calls") 36 | self.Cases = Module(self, "Cases") 37 | self.Campaigns = Module(self, "Campaigns") 38 | self.Contacts = Module(self, "Contacts") 39 | self.Documents = Module(self, "Documents") 40 | self.Email = Module(self, "Email") 41 | self.Emails = Module(self, "Emails") 42 | self.Employees = Module(self, "Employees") 43 | self.Leads = Module(self, "Leads") 44 | self.Lists = Module(self, "Lists") 45 | self.Meetings = Module(self, "Meetings") 46 | self.Notes = Module(self, "Notes") 47 | self.Opportunities = Module(self, "Opportunities") 48 | self.Projects = Module(self, "Projects") 49 | self.Spots = Module(self, "Spots") 50 | self.Surveys = Module(self, "Surveys") 51 | self.Target = Module(self, "Target") 52 | self.Targets = Module(self, "Targets") 53 | self.Tasks = Module(self, "Tasks") 54 | self.Templates = Module(self, "Templates") 55 | self.Candidates = Module(self, "Candidates") 56 | 57 | def _refresh_token(self) -> None: 58 | """ 59 | Fetch a new token from from token access url, specified in config file. 60 | :return: None 61 | """ 62 | try: 63 | self.OAuth2Session.fetch_token( 64 | token_url=self.baseurl[:-2] + "access_token", 65 | client_id=self._client_id, 66 | client_secret=self._client_secret, 67 | ) 68 | except InvalidClientError: 69 | exit("401 (Unauthorized) - client id/secret") 70 | except CustomOAuth2Error: 71 | exit("401 (Unauthorized) - client id") 72 | # Update configuration file with new token' 73 | with open("AccessToken.txt", "w+") as file: 74 | file.write(str(self.OAuth2Session.token)) 75 | 76 | def _login(self) -> None: 77 | """ 78 | Checks to see if a Oauth2 Session exists, if not builds a session and retrieves the token from the config file, 79 | if no token in config file, fetch a new one. 80 | 81 | :return: None 82 | """ 83 | # Does session exist? 84 | if not hasattr(self, "OAuth2Session"): 85 | client = BackendApplicationClient(client_id=self._client_id) 86 | self.OAuth2Session = OAuth2Session(client=client, client_id=self._client_id) 87 | self.OAuth2Session.headers.update( 88 | {"User-Agent": self._headers, "Content-Type": "application/json"} 89 | ) 90 | with open("AccessToken.txt", "w+") as file: 91 | token = file.read() 92 | if token == "": 93 | self._refresh_token() 94 | else: 95 | self.OAuth2Session.token = token 96 | else: 97 | self._refresh_token() 98 | 99 | # Logout on exit 100 | if self._logout_on_exit: 101 | atexit.register(self._logout) 102 | 103 | def _logout(self) -> None: 104 | """ 105 | Logs out current Oauth2 Session 106 | :return: None 107 | """ 108 | url = "/logout" 109 | self.request(f"{self.baseurl}{url}", "post") 110 | with open("AccessToken.txt", "w+") as file: 111 | file.write("") 112 | 113 | def request(self, url: str, method, parameters="") -> dict: 114 | """ 115 | Makes a request to the given url with a specific method and data. If the request fails because the token expired 116 | the session will re-authenticate and attempt the request again with a new token. 117 | 118 | :param url: (string) The url 119 | :param method: (string) Get, Post, Patch, Delete 120 | :param parameters: (dictionary) Data to be posted 121 | 122 | :return: (dictionary) Data 123 | """ 124 | 125 | url = quote(url, safe="/:?=&") 126 | data = json.dumps({"data": parameters}) 127 | try: 128 | the_method = getattr(self.OAuth2Session, method) 129 | except AttributeError: 130 | return 131 | 132 | try: 133 | if parameters == "": 134 | data = the_method(url) 135 | else: 136 | data = the_method(url, data=data) 137 | except TokenExpiredError: 138 | self._refresh_token() 139 | if parameters == "": 140 | data = the_method(url) 141 | else: 142 | data = the_method(url, data=data) 143 | 144 | # Revoked Token 145 | attempts = 0 146 | while data.status_code == 401 and attempts < 1: 147 | self._refresh_token() 148 | if parameters == "": 149 | data = the_method(url) 150 | else: 151 | data = the_method(url, data=data) 152 | attempts += 1 153 | if data.status_code == 401: 154 | exit( 155 | "401 (Unauthorized) client id/secret has been revoked, new token was attempted and failed." 156 | ) 157 | 158 | # Database Failure 159 | # SuiteCRM does not allow to query by a custom field see README, #Limitations 160 | if data.status_code == 400 and "Database failure." in data.content.decode(): 161 | raise Exception(data.content.decode()) 162 | 163 | return json.loads(data.content) 164 | 165 | def get_modules(self) -> list: 166 | """ 167 | Gets all the modules that are available in the SuiteCRM. 168 | :return: (list) A list of all the modules. 169 | """ 170 | url = "/meta/modules" 171 | module_response = self.request(f"{self.baseurl}{url}", "get") 172 | return list(module_response["data"]["attributes"].keys()) 173 | 174 | 175 | class Module: 176 | def __init__(self, suitecrm, module_name): 177 | self.module_name = module_name 178 | self.suitecrm = suitecrm 179 | 180 | def create(self, **attributes) -> dict: 181 | """ 182 | Creates a record with given attributes 183 | :param attributes: (**kwargs) fields with data you want to populate the record with. 184 | 185 | :return: (dictionary) The record that was created with the attributes. 186 | """ 187 | url = "/module" 188 | data = { 189 | "type": self.module_name, 190 | "id": str(uuid.uuid4()), 191 | "attributes": attributes, 192 | } 193 | return self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "post", data) 194 | 195 | def delete(self, record_id: str) -> dict: 196 | """ 197 | Delete a specific record by id. 198 | :param record_id: (string) The record id within the module you want to delete. 199 | 200 | :return: (dictionary) Confirmation of deletion of record. 201 | """ 202 | # Delete 203 | url = f"/module/{self.module_name}/{record_id}" 204 | return self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "delete") 205 | 206 | def fields(self) -> list: 207 | """ 208 | Gets all the attributes that can be set in a record. 209 | :return: (list) All the names of attributes in a record. 210 | """ 211 | # Get total record count 212 | url = f"/meta/fields/{self.module_name}" 213 | # Olka TODO 214 | result = self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "get") 215 | if "errors" in result: 216 | raise Exception(result["errors"]) 217 | if "data" in result: 218 | attributes = result["data"]["attributes"] 219 | 220 | # attributes = { 221 | # "attributes": { 222 | # "id": { 223 | # "vname": "LBL_ID", 224 | # "type": "id", 225 | # "required": "true", 226 | # "dbType": "id" 227 | # }, 228 | # "name": { 229 | # "vname": "LBL_SUBJECT", 230 | # "required": "true", 231 | # "type": "name", 232 | # "dbType": "varchar", 233 | # "len": "255" 234 | # }, 235 | # } 236 | # } 237 | # attributes is a list of dictionaries. We only need the keys and value of dbType 238 | # expected result: 239 | # attrs_shoprt = { 240 | # "id": { "dbType": "id" }, 241 | # "name": { "dbType": "varchar" } 242 | # } 243 | attrs_short = {} 244 | for key, value in attributes.items(): 245 | attrs_short[key] = {"dbType": value["dbType"]} 246 | # print(attrs_short) 247 | return attrs_short 248 | # return list(result['data'][0]['attributes'].keys()) 249 | raise Exception(result) 250 | 251 | def get( 252 | self, fields: list = None, sort: str = None, operator: str = "and", **filters 253 | ) -> list: 254 | """ 255 | Gets records given a specific id or filters, can be sorted only once, and the fields returned for each record 256 | can be specified. 257 | 258 | :param fields: (list) A list of fields you want to be returned from each record. 259 | :param sort: (string) The field you want the records to be sorted by. 260 | :param filters: (**kwargs) fields that the record has that you want to filter on. 261 | ie... date_start= {'operator': '>', 'value':'2020-05-08T09:59:00+00:00'} 262 | 263 | Important notice: we don’t support multiple level sorting right now! 264 | 265 | :return: (list) A list of dictionaries, where each dictionary is a record. 266 | """ 267 | # Fields Constructor 268 | if fields: 269 | fields = f"?fields[{self.module_name}]=" + ",".join(fields) 270 | url = f"/module/{self.module_name}{fields}&filter" 271 | else: 272 | url = f"/module/{self.module_name}?filter" 273 | if operator == "and" or operator == "or": 274 | url = f"{url}[operator]={operator}&filter" 275 | else: 276 | url = f"{url}[operator]=and&filter" # Olka TODO 277 | 278 | # Filter Constructor 279 | operators = { 280 | "=": "EQ", 281 | "<>": "NEQ", 282 | ">": "GT", 283 | ">=": "GTE", 284 | "<": "LT", 285 | "<=": "LTE", 286 | "LIKE": "LIKE", 287 | "NOT LIKE": "NOT_IKE", 288 | "IN": "IN", 289 | "NOT IN": "NOT_IN", 290 | } 291 | for field, value in filters.items(): 292 | if isinstance(value, dict): 293 | if value["operator"] == "BETWEEN": 294 | # in value there are two values separated by comma 295 | values = value["value"].split(",") 296 | url = f'{url}[{field}][{operators[">"]}]= {values[0]}&' 297 | url = f'{url}[{field}][{operators["<"]}]= {values[1]}&' 298 | 299 | else: 300 | url = f'{url}[{field}][{operators[value["operator"]]}]={value["value"]}&' 301 | else: 302 | url = f"{url}[{field}][eq]={value}&" 303 | url = url[:-1] 304 | 305 | # Sort 306 | if sort: 307 | url = f"{url}&sort=-{sort}" 308 | 309 | # Execute 310 | result = self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "get") 311 | # TODO Olka 312 | if "data" in result: 313 | return result["data"] 314 | if "errors" in result: 315 | raise Exception(result["errors"]) 316 | raise Exception(result["errors"]) 317 | 318 | # TODO 1. Returns list of strings instead of dicts. 2. To check if its necessary to handle pagination 319 | # def get_all(self, record_per_page: int = 100) -> list: 320 | # """ 321 | # Gets all the records in the module. 322 | # :return: (list) A list of dictionaries, where each dictionary is a record. 323 | # Will return all records within a module. 324 | # """ 325 | # # Get total record count 326 | # url = f"/module/{self.module_name}?page[number]=1&page[size]=1" 327 | # pages = ( 328 | # math.ceil( 329 | # self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "get")["meta"][ 330 | # "total-pages" 331 | # ] 332 | # / record_per_page 333 | # ) 334 | # + 1 335 | # ) 336 | # result = [] 337 | # for page in range(1, pages): 338 | # url = f"/module/{self.module_name}?page[number]={page}&page[size]={record_per_page}" 339 | # result.extend(self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "get")) 340 | 341 | # for record in result: 342 | # print(type(record)) 343 | # return result 344 | 345 | def get_all(self) -> dict: 346 | url = f"/module/{self.module_name}" 347 | return self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "get") 348 | 349 | def update(self, record_id: str, **attributes) -> dict: 350 | """ 351 | updates a record. 352 | 353 | :param record_id: (string) id of the current module record. 354 | :param attributes: (**kwargs) fields inside of the record to be updated. 355 | 356 | :return: (dictionary) The updated record 357 | """ 358 | url = "/module" 359 | data = {"type": self.module_name, "id": record_id, "attributes": attributes} 360 | return self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "patch", data) 361 | 362 | def get_relationship(self, record_id: str, related_module_name: str) -> dict: 363 | """ 364 | returns the relationship between this record and another module. 365 | 366 | :param record_id: (string) id of the current module record. 367 | :param related_module_name: (string) the module name you want to search relationships for, ie. Contacts. 368 | 369 | :return: (dictionary) A list of relationships that this module's record contains with the related module. 370 | """ 371 | url = f"/module/{self.module_name}/{record_id}/relationships/{related_module_name.lower()}" 372 | return self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "get") 373 | 374 | def create_relationship( 375 | self, record_id: str, related_module_name: str, related_bean_id: str 376 | ) -> dict: 377 | """ 378 | Creates a relationship between 2 records. 379 | 380 | :param record_id: (string) id of the current module record. 381 | :param related_module_name: (string) the module name of the record you want to create a relationship, 382 | ie. Contacts. 383 | :param related_bean_id: (string) id of the record inside of the other module. 384 | 385 | :return: (dictionary) A record that the relationship was created. 386 | """ 387 | # Post 388 | url = f"/module/{self.module_name}/{record_id}/relationships" 389 | data = {"type": related_module_name.capitalize(), "id": related_bean_id} 390 | return self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "post", data) 391 | 392 | def delete_relationship( 393 | self, record_id: str, related_module_name: str, related_bean_id: str 394 | ) -> dict: 395 | """ 396 | Deletes a relationship between 2 records. 397 | 398 | :param record_id: (string) id of the current module record. 399 | :param related_module_name: (string) the module name of the record you want to delete a relationship, 400 | ie. Contacts. 401 | :param related_bean_id: (string) id of the record inside of the other module. 402 | 403 | :return: (dictionary) A record that the relationship was deleted. 404 | """ 405 | url = f"/module/{self.module_name}/{record_id}/relationships/{related_module_name.lower()}/{related_bean_id}" 406 | return self.suitecrm.request(f"{self.suitecrm.baseurl}{url}", "delete") 407 | --------------------------------------------------------------------------------