diff --git a/README.md b/README.md index 7270f6e6..6d670be6 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,15 @@ pip install "agent-squad[openai]" Adds OpenAI's GPT models for agents and classification, along with core packages. -**4. Full Installation**: +**4. MiniMax Integration**: + + ```bash +pip install "agent-squad[minimax]" + ``` + +Adds [MiniMax](https://www.minimaxi.com/) models (MiniMax-M2.7, MiniMax-M2.7-highspeed) for agents and classification via the OpenAI-compatible API. + +**5. Full Installation**: ```bash pip install "agent-squad[all]" diff --git a/python/setup.cfg b/python/setup.cfg index b5719558..0ee25875 100644 --- a/python/setup.cfg +++ b/python/setup.cfg @@ -27,6 +27,8 @@ anthropic = anthropic>=0.49.0 openai = openai>=1.55.3 +minimax = + openai>=1.55.3 sql = libsql-client>=0.3.1 strands-agents = diff --git a/python/src/agent_squad/agents/__init__.py b/python/src/agent_squad/agents/__init__.py index 0b56f19c..2804b7f9 100644 --- a/python/src/agent_squad/agents/__init__.py +++ b/python/src/agent_squad/agents/__init__.py @@ -30,6 +30,12 @@ except ImportError: _OPENAI_AVAILABLE = False +try: + from .minimax_agent import MiniMaxAgent, MiniMaxAgentOptions + _MINIMAX_AVAILABLE = True +except ImportError: + _MINIMAX_AVAILABLE = False + try: from .strands_agent import StrandsAgent _STRANDS_AGENTS_AVAILABLE = True @@ -86,6 +92,12 @@ 'OpenAIAgentOptions' ]) +if _MINIMAX_AVAILABLE: + __all__.extend([ + 'MiniMaxAgent', + 'MiniMaxAgentOptions' + ]) + if _STRANDS_AGENTS_AVAILABLE: __all__.extend([ 'StrandsAgent', diff --git a/python/src/agent_squad/agents/minimax_agent.py b/python/src/agent_squad/agents/minimax_agent.py new file mode 100644 index 00000000..2e60395c --- /dev/null +++ b/python/src/agent_squad/agents/minimax_agent.py @@ -0,0 +1,214 @@ +from typing import AsyncIterable, Optional, Any, AsyncGenerator +from dataclasses import dataclass +from openai import OpenAI +from agent_squad.agents import ( + Agent, + AgentOptions, + AgentStreamResponse +) +from agent_squad.types import ( + ConversationMessage, + ParticipantRole, + MINIMAX_MODEL_ID_M2_7, + TemplateVariables +) +from agent_squad.utils import Logger +from agent_squad.retrievers import Retriever + + +MINIMAX_API_BASE_URL = "https://api.minimax.io/v1" + + +@dataclass +class MiniMaxAgentOptions(AgentOptions): + api_key: str = None + model: Optional[str] = None + streaming: Optional[bool] = None + inference_config: Optional[dict[str, Any]] = None + custom_system_prompt: Optional[dict[str, Any]] = None + retriever: Optional[Retriever] = None + client: Optional[Any] = None + base_url: Optional[str] = None + + +class MiniMaxAgent(Agent): + def __init__(self, options: MiniMaxAgentOptions): + super().__init__(options) + if not options.api_key: + raise ValueError("MiniMax API key is required") + + if options.client: + self.client = options.client + else: + self.client = OpenAI( + api_key=options.api_key, + base_url=options.base_url or MINIMAX_API_BASE_URL + ) + + self.model = options.model or MINIMAX_MODEL_ID_M2_7 + self.streaming = options.streaming or False + self.retriever: Optional[Retriever] = options.retriever + + # Default inference configuration + default_inference_config = { + 'maxTokens': 1000, + 'temperature': None, + 'topP': None, + 'stopSequences': None + } + + if options.inference_config: + self.inference_config = {**default_inference_config, **options.inference_config} + else: + self.inference_config = default_inference_config + + # Clamp temperature for MiniMax API (must be > 0 when set) + temp = self.inference_config.get('temperature') + if temp is not None and temp <= 0: + self.inference_config['temperature'] = 0.01 + + # Initialize system prompt + self.prompt_template = f"""You are a {self.name}. + {self.description} Provide helpful and accurate information based on your expertise. + You will engage in an open-ended conversation, providing helpful and accurate information based on your expertise. + The conversation will proceed as follows: + - The human may ask an initial question or provide a prompt on any topic. + - You will provide a relevant and informative response. + - The human may then follow up with additional questions or prompts related to your previous response, + allowing for a multi-turn dialogue on that topic. + - Or, the human may switch to a completely new and unrelated topic at any point. + - You will seamlessly shift your focus to the new topic, providing thoughtful and coherent responses + based on your broad knowledge base. + Throughout the conversation, you should aim to: + - Understand the context and intent behind each new question or prompt. + - Provide substantive and well-reasoned responses that directly address the query. + - Draw insights and connections from your extensive knowledge when appropriate. + - Ask for clarification if any part of the question or prompt is ambiguous. + - Maintain a consistent, respectful, and engaging tone tailored to the human's communication style. + - Seamlessly transition between topics as the human introduces new subjects.""" + + self.system_prompt = "" + self.custom_variables: TemplateVariables = {} + + if options.custom_system_prompt: + self.set_system_prompt( + options.custom_system_prompt.get('template'), + options.custom_system_prompt.get('variables') + ) + + def is_streaming_enabled(self) -> bool: + return self.streaming is True + + async def process_request( + self, + input_text: str, + user_id: str, + session_id: str, + chat_history: list[ConversationMessage], + additional_params: Optional[dict[str, str]] = None + ) -> ConversationMessage | AsyncIterable[Any]: + try: + self.update_system_prompt() + + system_prompt = self.system_prompt + + if self.retriever: + response = await self.retriever.retrieve_and_combine_results(input_text) + context_prompt = "\nHere is the context to use to answer the user's question:\n" + response + system_prompt += context_prompt + + messages = [ + {"role": "system", "content": system_prompt}, + *[{ + "role": msg.role.lower(), + "content": msg.content[0].get('text', '') if msg.content else '' + } for msg in chat_history], + {"role": "user", "content": input_text} + ] + + request_options = { + "model": self.model, + "messages": messages, + "max_tokens": self.inference_config.get('maxTokens'), + "temperature": self.inference_config.get('temperature'), + "top_p": self.inference_config.get('topP'), + "stop": self.inference_config.get('stopSequences'), + "stream": self.streaming + } + if self.streaming: + return self.handle_streaming_response(request_options) + else: + return await self.handle_single_response(request_options) + + except Exception as error: + Logger.error(f"Error in MiniMax API call: {str(error)}") + raise error + + async def handle_single_response(self, request_options: dict[str, Any]) -> ConversationMessage: + try: + request_options['stream'] = False + chat_completion = self.client.chat.completions.create(**request_options) + + if not chat_completion.choices: + raise ValueError('No choices returned from MiniMax API') + + assistant_message = chat_completion.choices[0].message.content + + if not isinstance(assistant_message, str): + raise ValueError('Unexpected response format from MiniMax API') + + return ConversationMessage( + role=ParticipantRole.ASSISTANT.value, + content=[{"text": assistant_message}] + ) + + except Exception as error: + Logger.error(f'Error in MiniMax API call: {str(error)}') + raise error + + async def handle_streaming_response(self, request_options: dict[str, Any]) -> AsyncGenerator[AgentStreamResponse, None]: + try: + stream = self.client.chat.completions.create(**request_options) + accumulated_message = [] + + for chunk in stream: + if chunk.choices[0].delta.content: + chunk_content = chunk.choices[0].delta.content + accumulated_message.append(chunk_content) + await self.callbacks.on_llm_new_token(chunk_content) + yield AgentStreamResponse(text=chunk_content) + + # Store the complete message in the instance for later access if needed + yield AgentStreamResponse(final_message=ConversationMessage( + role=ParticipantRole.ASSISTANT.value, + content=[{"text": ''.join(accumulated_message)}] + )) + + except Exception as error: + Logger.error(f"Error getting stream from MiniMax model: {str(error)}") + raise error + + def set_system_prompt(self, + template: Optional[str] = None, + variables: Optional[TemplateVariables] = None) -> None: + if template: + self.prompt_template = template + if variables: + self.custom_variables = variables + self.update_system_prompt() + + def update_system_prompt(self) -> None: + all_variables: TemplateVariables = {**self.custom_variables} + self.system_prompt = self.replace_placeholders(self.prompt_template, all_variables) + + @staticmethod + def replace_placeholders(template: str, variables: TemplateVariables) -> str: + import re + def replace(match): + key = match.group(1) + if key in variables: + value = variables[key] + return '\n'.join(value) if isinstance(value, list) else str(value) + return match.group(0) + + return re.sub(r'{{(\w+)}}', replace, template) diff --git a/python/src/agent_squad/classifiers/__init__.py b/python/src/agent_squad/classifiers/__init__.py index 928adc31..34a7f93a 100644 --- a/python/src/agent_squad/classifiers/__init__.py +++ b/python/src/agent_squad/classifiers/__init__.py @@ -21,6 +21,12 @@ except Exception as e: _OPENAI_AVAILABLE = False +try: + from .minimax_classifier import MiniMaxClassifier, MiniMaxClassifierOptions + _MINIMAX_AVAILABLE = True +except Exception as e: + _MINIMAX_AVAILABLE = False + __all__ = [ "Classifier", "ClassifierResult", @@ -43,4 +49,10 @@ __all__.extend([ "OpenAIClassifier", "OpenAIClassifierOptions" + ]) + +if _MINIMAX_AVAILABLE: + __all__.extend([ + "MiniMaxClassifier", + "MiniMaxClassifierOptions" ]) \ No newline at end of file diff --git a/python/src/agent_squad/classifiers/minimax_classifier.py b/python/src/agent_squad/classifiers/minimax_classifier.py new file mode 100644 index 00000000..37bc9b98 --- /dev/null +++ b/python/src/agent_squad/classifiers/minimax_classifier.py @@ -0,0 +1,120 @@ +import json +from typing import List, Optional, Dict, Any +from openai import OpenAI +from agent_squad.utils.helpers import is_tool_input +from agent_squad.utils.logger import Logger +from agent_squad.types import ConversationMessage +from agent_squad.classifiers import Classifier, ClassifierResult + + +MINIMAX_API_BASE_URL = "https://api.minimax.io/v1" +MINIMAX_MODEL_ID_M2_7 = "MiniMax-M2.7" + + +class MiniMaxClassifierOptions: + def __init__(self, + api_key: str, + model_id: Optional[str] = None, + inference_config: Optional[Dict[str, Any]] = None, + base_url: Optional[str] = None): + self.api_key = api_key + self.model_id = model_id + self.inference_config = inference_config or {} + self.base_url = base_url + + +class MiniMaxClassifier(Classifier): + def __init__(self, options: MiniMaxClassifierOptions): + super().__init__() + + if not options.api_key: + raise ValueError("MiniMax API key is required") + + self.client = OpenAI( + api_key=options.api_key, + base_url=options.base_url or MINIMAX_API_BASE_URL + ) + self.model_id = options.model_id or MINIMAX_MODEL_ID_M2_7 + + default_max_tokens = 1000 + # Clamp temperature for MiniMax API (must be > 0 when set) + temperature = options.inference_config.get('temperature', 0.1) + if temperature <= 0: + temperature = 0.01 + + self.inference_config = { + 'max_tokens': options.inference_config.get('max_tokens', default_max_tokens), + 'temperature': temperature, + 'top_p': options.inference_config.get('top_p', 0.9), + 'stop': options.inference_config.get('stop_sequences', []), + } + + self.tools = [ + { + 'type': 'function', + 'function': { + 'name': 'analyzePrompt', + 'description': 'Analyze the user input and provide structured output', + 'parameters': { + 'type': 'object', + 'properties': { + 'userinput': { + 'type': 'string', + 'description': 'The original user input', + }, + 'selected_agent': { + 'type': 'string', + 'description': 'The name of the selected agent', + }, + 'confidence': { + 'type': 'number', + 'description': 'Confidence level between 0 and 1', + }, + }, + 'required': ['userinput', 'selected_agent', 'confidence'], + }, + }, + } + ] + + self.system_prompt = "You are an AI assistant." + + async def process_request(self, + input_text: str, + chat_history: List[ConversationMessage]) -> ClassifierResult: + messages = [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": input_text} + ] + + try: + response = self.client.chat.completions.create( + model=self.model_id, + messages=messages, + max_tokens=self.inference_config['max_tokens'], + temperature=self.inference_config['temperature'], + top_p=self.inference_config['top_p'], + tools=self.tools, + tool_choice={"type": "function", "function": {"name": "analyzePrompt"}} + ) + + tool_call = response.choices[0].message.tool_calls[0] + + if not tool_call or tool_call.function.name != "analyzePrompt": + raise ValueError("No valid tool call found in the response") + + tool_input = json.loads(tool_call.function.arguments) + + if not is_tool_input(tool_input): + raise ValueError("Tool input does not match expected structure") + + intent_classifier_result = ClassifierResult( + selected_agent=self.get_agent_by_id(tool_input['selected_agent']), + confidence=float(tool_input['confidence']) + ) + + return intent_classifier_result + + except Exception as error: + Logger.error(f"Error processing request: {str(error)}") + raise error diff --git a/python/src/agent_squad/types/__init__.py b/python/src/agent_squad/types/__init__.py index cd1d3857..4ca52ebf 100644 --- a/python/src/agent_squad/types/__init__.py +++ b/python/src/agent_squad/types/__init__.py @@ -12,6 +12,8 @@ BEDROCK_MODEL_ID_LLAMA_3_70B, OPENAI_MODEL_ID_GPT_O_MINI, ANTHROPIC_MODEL_ID_CLAUDE_3_5_SONNET, + MINIMAX_MODEL_ID_M2_7, + MINIMAX_MODEL_ID_M2_7_HIGHSPEED, TemplateVariables, AgentSquadConfig, AgentProviderType, @@ -30,6 +32,8 @@ 'BEDROCK_MODEL_ID_LLAMA_3_70B', 'OPENAI_MODEL_ID_GPT_O_MINI', 'ANTHROPIC_MODEL_ID_CLAUDE_3_5_SONNET', + 'MINIMAX_MODEL_ID_M2_7', + 'MINIMAX_MODEL_ID_M2_7_HIGHSPEED', 'TemplateVariables', 'AgentSquadConfig', 'AgentProviderType', diff --git a/python/src/agent_squad/types/types.py b/python/src/agent_squad/types/types.py index 29d71a78..91f7d940 100644 --- a/python/src/agent_squad/types/types.py +++ b/python/src/agent_squad/types/types.py @@ -11,6 +11,8 @@ BEDROCK_MODEL_ID_LLAMA_3_70B = "meta.llama3-70b-instruct-v1:0" OPENAI_MODEL_ID_GPT_O_MINI = "gpt-4o-mini" ANTHROPIC_MODEL_ID_CLAUDE_3_5_SONNET = "claude-3-5-sonnet-20240620" +MINIMAX_MODEL_ID_M2_7 = "MiniMax-M2.7" +MINIMAX_MODEL_ID_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" class AgentProviderType(Enum): BEDROCK = "BEDROCK" diff --git a/python/src/tests/agents/test_minimax_agent.py b/python/src/tests/agents/test_minimax_agent.py new file mode 100644 index 00000000..6a4f1ab2 --- /dev/null +++ b/python/src/tests/agents/test_minimax_agent.py @@ -0,0 +1,373 @@ +import pytest +from unittest.mock import Mock, AsyncMock, patch, MagicMock +from typing import AsyncIterable +from agent_squad.types import ConversationMessage, ParticipantRole +from agent_squad.agents import AgentStreamResponse +from agent_squad.agents.minimax_agent import MiniMaxAgent, MiniMaxAgentOptions, MINIMAX_API_BASE_URL + + +@pytest.fixture +def mock_openai_client(): + mock_client = Mock() + mock_client.chat = Mock() + mock_client.chat.completions = Mock() + mock_client.chat.completions.create = Mock() + return mock_client + + +@pytest.fixture +def minimax_agent(mock_openai_client): + with patch('agent_squad.agents.minimax_agent.OpenAI', return_value=mock_openai_client): + options = MiniMaxAgentOptions( + name="TestMiniMaxAgent", + description="A test MiniMax agent", + api_key="test-api-key", + model="MiniMax-M2.7", + streaming=False, + inference_config={ + 'maxTokens': 500, + 'temperature': 0.5, + 'topP': 0.8, + 'stopSequences': [] + } + ) + agent = MiniMaxAgent(options) + agent.client = mock_openai_client + return agent + + +class TestMiniMaxAgentInit: + def test_requires_api_key(self): + with pytest.raises(ValueError, match="MiniMax API key is required"): + MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key=None + )) + + def test_default_model(self): + with patch('agent_squad.agents.minimax_agent.OpenAI'): + agent = MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key" + )) + assert agent.model == "MiniMax-M2.7" + + def test_custom_model(self): + with patch('agent_squad.agents.minimax_agent.OpenAI'): + agent = MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key", + model="MiniMax-M2.7-highspeed" + )) + assert agent.model == "MiniMax-M2.7-highspeed" + + def test_default_base_url(self): + with patch('agent_squad.agents.minimax_agent.OpenAI') as mock_openai: + MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key" + )) + mock_openai.assert_called_once_with( + api_key="test-key", + base_url=MINIMAX_API_BASE_URL + ) + + def test_custom_base_url(self): + with patch('agent_squad.agents.minimax_agent.OpenAI') as mock_openai: + MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key", + base_url="https://custom.api.example.com/v1" + )) + mock_openai.assert_called_once_with( + api_key="test-key", + base_url="https://custom.api.example.com/v1" + ) + + def test_custom_client(self): + mock_client = Mock() + with patch('agent_squad.agents.minimax_agent.OpenAI') as mock_openai: + agent = MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key", + client=mock_client + )) + assert agent.client is mock_client + mock_openai.assert_not_called() + + def test_temperature_clamping_zero(self): + with patch('agent_squad.agents.minimax_agent.OpenAI'): + agent = MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key", + inference_config={'temperature': 0} + )) + assert agent.inference_config['temperature'] == 0.01 + + def test_temperature_clamping_negative(self): + with patch('agent_squad.agents.minimax_agent.OpenAI'): + agent = MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key", + inference_config={'temperature': -0.5} + )) + assert agent.inference_config['temperature'] == 0.01 + + def test_temperature_positive_not_clamped(self): + with patch('agent_squad.agents.minimax_agent.OpenAI'): + agent = MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key", + inference_config={'temperature': 0.7} + )) + assert agent.inference_config['temperature'] == 0.7 + + def test_temperature_none_not_clamped(self): + with patch('agent_squad.agents.minimax_agent.OpenAI'): + agent = MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key" + )) + assert agent.inference_config['temperature'] is None + + def test_default_inference_config(self): + with patch('agent_squad.agents.minimax_agent.OpenAI'): + agent = MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key" + )) + assert agent.inference_config['maxTokens'] == 1000 + assert agent.inference_config['temperature'] is None + assert agent.inference_config['topP'] is None + assert agent.inference_config['stopSequences'] is None + + def test_custom_inference_config(self): + with patch('agent_squad.agents.minimax_agent.OpenAI'): + agent = MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key", + inference_config={ + 'maxTokens': 2000, + 'temperature': 0.8, + 'topP': 0.95 + } + )) + assert agent.inference_config['maxTokens'] == 2000 + assert agent.inference_config['temperature'] == 0.8 + assert agent.inference_config['topP'] == 0.95 + + +class TestMiniMaxAgentSystemPrompt: + def test_custom_system_prompt_with_variable(self): + with patch('agent_squad.agents.minimax_agent.OpenAI'): + options = MiniMaxAgentOptions( + name="TestAgent", + description="A test agent", + api_key="test-api-key", + custom_system_prompt={ + 'template': "This is a prompt with {{variable}}", + 'variables': {'variable': 'value'} + } + ) + agent = MiniMaxAgent(options) + assert agent.system_prompt == "This is a prompt with value" + + def test_set_system_prompt(self): + with patch('agent_squad.agents.minimax_agent.OpenAI'): + agent = MiniMaxAgent(MiniMaxAgentOptions( + name="Test", + description="Test", + api_key="test-key" + )) + agent.set_system_prompt( + template="Hello {{name}}, you are a {{role}}", + variables={'name': 'MiniMax', 'role': 'assistant'} + ) + assert agent.system_prompt == "Hello MiniMax, you are a assistant" + + def test_replace_placeholders_with_list(self): + result = MiniMaxAgent.replace_placeholders( + "Items: {{items}}", + {'items': ['one', 'two', 'three']} + ) + assert result == "Items: one\ntwo\nthree" + + def test_replace_placeholders_unknown_variable(self): + result = MiniMaxAgent.replace_placeholders( + "Hello {{unknown}}", + {} + ) + assert result == "Hello {{unknown}}" + + +class TestMiniMaxAgentProcessRequest: + @pytest.mark.asyncio + async def test_process_request_success(self, minimax_agent, mock_openai_client): + mock_response = Mock() + mock_response.choices = [Mock()] + mock_response.choices[0].message = Mock() + mock_response.choices[0].message.content = "This is a test response" + mock_openai_client.chat.completions.create.return_value = mock_response + + result = await minimax_agent.process_request( + "Test question", + "test_user", + "test_session", + [] + ) + + assert isinstance(result, ConversationMessage) + assert result.role == ParticipantRole.ASSISTANT.value + assert result.content[0]['text'] == 'This is a test response' + + @pytest.mark.asyncio + async def test_process_request_streaming(self, minimax_agent, mock_openai_client): + minimax_agent.streaming = True + + class MockChunk: + def __init__(self, content): + self.choices = [Mock()] + self.choices[0].delta = Mock() + self.choices[0].delta.content = content + + mock_stream = [ + MockChunk("This "), + MockChunk("is "), + MockChunk("a "), + MockChunk("test response") + ] + mock_openai_client.chat.completions.create.return_value = mock_stream + + result = await minimax_agent.process_request( + "Test question", + "test_user", + "test_session", + [] + ) + + assert isinstance(result, AsyncIterable) + chunks = [] + async for chunk in result: + assert isinstance(chunk, AgentStreamResponse) + if chunk.text: + chunks.append(chunk.text) + elif chunk.final_message: + assert chunk.final_message.role == ParticipantRole.ASSISTANT.value + assert chunk.final_message.content[0]['text'] == 'This is a test response' + assert chunks == ["This ", "is ", "a ", "test response"] + + @pytest.mark.asyncio + async def test_process_request_with_retriever(self, minimax_agent, mock_openai_client): + mock_retriever = AsyncMock() + mock_retriever.retrieve_and_combine_results.return_value = "Context from retriever" + minimax_agent.retriever = mock_retriever + + mock_response = Mock() + mock_response.choices = [Mock()] + mock_response.choices[0].message = Mock() + mock_response.choices[0].message.content = "Response with context" + mock_openai_client.chat.completions.create.return_value = mock_response + + result = await minimax_agent.process_request( + "Test question", + "test_user", + "test_session", + [] + ) + + mock_retriever.retrieve_and_combine_results.assert_called_once_with("Test question") + assert isinstance(result, ConversationMessage) + assert result.content[0]['text'] == "Response with context" + + @pytest.mark.asyncio + async def test_process_request_api_error(self, minimax_agent, mock_openai_client): + mock_openai_client.chat.completions.create.side_effect = Exception("API Error") + + with pytest.raises(Exception) as exc_info: + await minimax_agent.process_request( + "Test input", + "user123", + "session456", + [] + ) + assert "API Error" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_process_request_with_chat_history(self, minimax_agent, mock_openai_client): + mock_response = Mock() + mock_response.choices = [Mock()] + mock_response.choices[0].message = Mock() + mock_response.choices[0].message.content = "Follow-up response" + mock_openai_client.chat.completions.create.return_value = mock_response + + chat_history = [ + ConversationMessage( + role=ParticipantRole.USER.value, + content=[{"text": "Previous question"}] + ), + ConversationMessage( + role=ParticipantRole.ASSISTANT.value, + content=[{"text": "Previous answer"}] + ) + ] + + result = await minimax_agent.process_request( + "Follow-up question", + "test_user", + "test_session", + chat_history + ) + + assert isinstance(result, ConversationMessage) + assert result.content[0]['text'] == "Follow-up response" + + call_args = mock_openai_client.chat.completions.create.call_args + messages = call_args.kwargs.get('messages', call_args[1].get('messages', [])) + assert len(messages) == 4 # system + 2 history + user + + +class TestMiniMaxAgentHandlers: + @pytest.mark.asyncio + async def test_handle_single_response_no_choices(self, minimax_agent, mock_openai_client): + mock_response = Mock() + mock_response.choices = [] + mock_openai_client.chat.completions.create.return_value = mock_response + + with pytest.raises(ValueError, match='No choices returned from MiniMax API'): + await minimax_agent.handle_single_response({ + "model": "MiniMax-M2.7", + "messages": [{"role": "user", "content": "Hi"}], + "stream": False + }) + + @pytest.mark.asyncio + async def test_handle_single_response_non_string(self, minimax_agent, mock_openai_client): + mock_response = Mock() + mock_response.choices = [Mock()] + mock_response.choices[0].message = Mock() + mock_response.choices[0].message.content = 12345 # Not a string + mock_openai_client.chat.completions.create.return_value = mock_response + + with pytest.raises(ValueError, match='Unexpected response format from MiniMax API'): + await minimax_agent.handle_single_response({ + "model": "MiniMax-M2.7", + "messages": [{"role": "user", "content": "Hi"}], + "stream": False + }) + + def test_is_streaming_enabled(self, minimax_agent): + assert not minimax_agent.is_streaming_enabled() + minimax_agent.streaming = True + assert minimax_agent.is_streaming_enabled() diff --git a/python/src/tests/agents/test_minimax_agent_integration.py b/python/src/tests/agents/test_minimax_agent_integration.py new file mode 100644 index 00000000..0ebdb667 --- /dev/null +++ b/python/src/tests/agents/test_minimax_agent_integration.py @@ -0,0 +1,112 @@ +""" +Integration tests for MiniMaxAgent. +These tests verify the agent works with the actual MiniMax API. +They are skipped by default unless MINIMAX_API_KEY is set. +""" +import os +import pytest +from agent_squad.types import ConversationMessage, ParticipantRole +from agent_squad.agents import AgentStreamResponse + + +MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY") +skip_no_key = pytest.mark.skipif( + not MINIMAX_API_KEY, + reason="MINIMAX_API_KEY not set" +) + + +@skip_no_key +class TestMiniMaxAgentIntegration: + @pytest.fixture + def agent(self): + from agent_squad.agents.minimax_agent import MiniMaxAgent, MiniMaxAgentOptions + return MiniMaxAgent(MiniMaxAgentOptions( + name="TestAgent", + description="A helpful test agent", + api_key=MINIMAX_API_KEY, + model="MiniMax-M2.7", + streaming=False, + inference_config={ + 'maxTokens': 100, + 'temperature': 0.5, + } + )) + + @pytest.fixture + def streaming_agent(self): + from agent_squad.agents.minimax_agent import MiniMaxAgent, MiniMaxAgentOptions + return MiniMaxAgent(MiniMaxAgentOptions( + name="TestStreamingAgent", + description="A helpful test agent with streaming", + api_key=MINIMAX_API_KEY, + model="MiniMax-M2.7", + streaming=True, + inference_config={ + 'maxTokens': 100, + 'temperature': 0.5, + } + )) + + @pytest.mark.asyncio + async def test_single_response(self, agent): + result = await agent.process_request( + "What is 2 + 2? Answer with just the number.", + "test_user", + "test_session", + [] + ) + + assert isinstance(result, ConversationMessage) + assert result.role == ParticipantRole.ASSISTANT.value + assert result.content + assert "4" in result.content[0]['text'] + + @pytest.mark.asyncio + async def test_streaming_response(self, streaming_agent): + result = await streaming_agent.process_request( + "Say 'hello world' and nothing else.", + "test_user", + "test_session", + [] + ) + + chunks = [] + final_message = None + async for chunk in result: + assert isinstance(chunk, AgentStreamResponse) + if chunk.text: + chunks.append(chunk.text) + if chunk.final_message: + final_message = chunk.final_message + + assert len(chunks) > 0 + assert final_message is not None + assert final_message.role == ParticipantRole.ASSISTANT.value + + @pytest.mark.asyncio + async def test_multi_turn_conversation(self, agent): + result1 = await agent.process_request( + "My name is Alice.", + "test_user", + "test_session", + [] + ) + assert isinstance(result1, ConversationMessage) + + chat_history = [ + ConversationMessage( + role=ParticipantRole.USER.value, + content=[{"text": "My name is Alice."}] + ), + result1 + ] + + result2 = await agent.process_request( + "What is my name?", + "test_user", + "test_session", + chat_history + ) + assert isinstance(result2, ConversationMessage) + assert "Alice" in result2.content[0]['text'] diff --git a/python/src/tests/classifiers/test_minimax_classifier.py b/python/src/tests/classifiers/test_minimax_classifier.py new file mode 100644 index 00000000..32113e46 --- /dev/null +++ b/python/src/tests/classifiers/test_minimax_classifier.py @@ -0,0 +1,169 @@ +import pytest +import json +from unittest.mock import Mock, patch, MagicMock +from agent_squad.classifiers.minimax_classifier import ( + MiniMaxClassifier, + MiniMaxClassifierOptions, + MINIMAX_API_BASE_URL, + MINIMAX_MODEL_ID_M2_7 +) +from agent_squad.types import ConversationMessage + + +@pytest.fixture +def mock_openai_client(): + mock_client = Mock() + mock_client.chat = Mock() + mock_client.chat.completions = Mock() + mock_client.chat.completions.create = Mock() + return mock_client + + +@pytest.fixture +def minimax_classifier(mock_openai_client): + with patch('agent_squad.classifiers.minimax_classifier.OpenAI', return_value=mock_openai_client): + options = MiniMaxClassifierOptions( + api_key="test-api-key", + model_id="MiniMax-M2.7", + inference_config={ + 'max_tokens': 500, + 'temperature': 0.5, + 'top_p': 0.9 + } + ) + classifier = MiniMaxClassifier(options) + classifier.client = mock_openai_client + return classifier + + +class TestMiniMaxClassifierInit: + def test_requires_api_key(self): + with pytest.raises(ValueError, match="MiniMax API key is required"): + MiniMaxClassifier(MiniMaxClassifierOptions( + api_key=None + )) + + def test_default_model(self): + with patch('agent_squad.classifiers.minimax_classifier.OpenAI'): + classifier = MiniMaxClassifier(MiniMaxClassifierOptions( + api_key="test-key" + )) + assert classifier.model_id == MINIMAX_MODEL_ID_M2_7 + + def test_custom_model(self): + with patch('agent_squad.classifiers.minimax_classifier.OpenAI'): + classifier = MiniMaxClassifier(MiniMaxClassifierOptions( + api_key="test-key", + model_id="MiniMax-M2.7-highspeed" + )) + assert classifier.model_id == "MiniMax-M2.7-highspeed" + + def test_default_base_url(self): + with patch('agent_squad.classifiers.minimax_classifier.OpenAI') as mock_openai: + MiniMaxClassifier(MiniMaxClassifierOptions( + api_key="test-key" + )) + mock_openai.assert_called_once_with( + api_key="test-key", + base_url=MINIMAX_API_BASE_URL + ) + + def test_custom_base_url(self): + with patch('agent_squad.classifiers.minimax_classifier.OpenAI') as mock_openai: + MiniMaxClassifier(MiniMaxClassifierOptions( + api_key="test-key", + base_url="https://custom.example.com/v1" + )) + mock_openai.assert_called_once_with( + api_key="test-key", + base_url="https://custom.example.com/v1" + ) + + def test_temperature_clamping_zero(self): + with patch('agent_squad.classifiers.minimax_classifier.OpenAI'): + classifier = MiniMaxClassifier(MiniMaxClassifierOptions( + api_key="test-key", + inference_config={'temperature': 0} + )) + assert classifier.inference_config['temperature'] == 0.01 + + def test_temperature_clamping_negative(self): + with patch('agent_squad.classifiers.minimax_classifier.OpenAI'): + classifier = MiniMaxClassifier(MiniMaxClassifierOptions( + api_key="test-key", + inference_config={'temperature': -1.0} + )) + assert classifier.inference_config['temperature'] == 0.01 + + def test_temperature_positive_not_clamped(self): + with patch('agent_squad.classifiers.minimax_classifier.OpenAI'): + classifier = MiniMaxClassifier(MiniMaxClassifierOptions( + api_key="test-key", + inference_config={'temperature': 0.7} + )) + assert classifier.inference_config['temperature'] == 0.7 + + def test_default_inference_config(self): + with patch('agent_squad.classifiers.minimax_classifier.OpenAI'): + classifier = MiniMaxClassifier(MiniMaxClassifierOptions( + api_key="test-key" + )) + assert classifier.inference_config['max_tokens'] == 1000 + assert classifier.inference_config['temperature'] == 0.1 + assert classifier.inference_config['top_p'] == 0.9 + + +class TestMiniMaxClassifierProcessRequest: + @pytest.mark.asyncio + async def test_process_request_success(self, minimax_classifier, mock_openai_client): + # Set up a mock agent - key must match get_agent_by_id logic (lowercase) + mock_agent = Mock() + mock_agent.name = "TestAgent" + mock_agent.id = "testagent" + minimax_classifier.agents = {"testagent": mock_agent} + minimax_classifier.agent_descriptions = "testagent: A test agent" + + # Set up mock response + mock_tool_call = Mock() + mock_tool_call.function = Mock() + mock_tool_call.function.name = "analyzePrompt" + mock_tool_call.function.arguments = json.dumps({ + "userinput": "Test input", + "selected_agent": "testagent", + "confidence": 0.95 + }) + + mock_response = Mock() + mock_response.choices = [Mock()] + mock_response.choices[0].message = Mock() + mock_response.choices[0].message.tool_calls = [mock_tool_call] + mock_openai_client.chat.completions.create.return_value = mock_response + + result = await minimax_classifier.process_request("Test input", []) + + assert result.selected_agent == mock_agent + assert result.confidence == 0.95 + + @pytest.mark.asyncio + async def test_process_request_api_error(self, minimax_classifier, mock_openai_client): + mock_openai_client.chat.completions.create.side_effect = Exception("API Error") + + with pytest.raises(Exception) as exc_info: + await minimax_classifier.process_request("Test input", []) + assert "API Error" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_process_request_invalid_tool_call(self, minimax_classifier, mock_openai_client): + mock_tool_call = Mock() + mock_tool_call.function = Mock() + mock_tool_call.function.name = "wrongFunction" + mock_tool_call.function.arguments = json.dumps({}) + + mock_response = Mock() + mock_response.choices = [Mock()] + mock_response.choices[0].message = Mock() + mock_response.choices[0].message.tool_calls = [mock_tool_call] + mock_openai_client.chat.completions.create.return_value = mock_response + + with pytest.raises(ValueError, match="No valid tool call found"): + await minimax_classifier.process_request("Test input", []) diff --git a/python/src/tests/classifiers/test_minimax_classifier_integration.py b/python/src/tests/classifiers/test_minimax_classifier_integration.py new file mode 100644 index 00000000..3893082a --- /dev/null +++ b/python/src/tests/classifiers/test_minimax_classifier_integration.py @@ -0,0 +1,62 @@ +""" +Integration tests for MiniMaxClassifier. +These tests verify the classifier works with the actual MiniMax API. +They are skipped by default unless MINIMAX_API_KEY is set. +""" +import os +import pytest +from agent_squad.agents import Agent, AgentOptions +from agent_squad.types import ConversationMessage, ParticipantRole + + +MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY") +skip_no_key = pytest.mark.skipif( + not MINIMAX_API_KEY, + reason="MINIMAX_API_KEY not set" +) + + +class MockAgent(Agent): + """A minimal agent for testing the classifier.""" + async def process_request(self, input_text, user_id, session_id, chat_history, additional_params=None): + return ConversationMessage( + role=ParticipantRole.ASSISTANT.value, + content=[{"text": f"Response from {self.name}"}] + ) + + +@skip_no_key +class TestMiniMaxClassifierIntegration: + @pytest.fixture + def classifier(self): + from agent_squad.classifiers.minimax_classifier import MiniMaxClassifier, MiniMaxClassifierOptions + classifier = MiniMaxClassifier(MiniMaxClassifierOptions( + api_key=MINIMAX_API_KEY, + model_id="MiniMax-M2.7", + inference_config={ + 'max_tokens': 200, + 'temperature': 0.1, + } + )) + + tech_agent = MockAgent(AgentOptions( + name="TechAgent", + description="Handles technology and programming questions" + )) + health_agent = MockAgent(AgentOptions( + name="HealthAgent", + description="Handles health and medical questions" + )) + + classifier.set_agents({tech_agent.id: tech_agent, health_agent.id: health_agent}) + return classifier + + @pytest.mark.asyncio + async def test_classify_tech_question(self, classifier): + result = await classifier.classify( + "How do I write a Python function?", + [] + ) + assert result is not None + assert result.selected_agent is not None + assert result.confidence > 0