diff --git a/.github/workflows/ag2.yml b/.github/workflows/ag2.yml new file mode 100644 index 0000000000..bddcf71794 --- /dev/null +++ b/.github/workflows/ag2.yml @@ -0,0 +1,133 @@ +# This workflow comes from https://github.com/ofek/hatch-mypyc +# https://github.com/ofek/hatch-mypyc/blob/5a198c0ba8660494d02716cfc9d79ce4adfb1442/.github/workflows/test.yml +name: Test / ag2 + +on: + schedule: + - cron: "0 0 * * *" + pull_request: + paths: + - "integrations/ag2/**" + - "!integrations/ag2/*.md" + - ".github/workflows/ag2.yml" + push: + branches: + - main + paths: + - "integrations/ag2/**" + - "!integrations/ag2/*.md" + - ".github/workflows/ag2.yml" + +defaults: + run: + working-directory: integrations/ag2 + +concurrency: + group: ag2-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +env: + PYTHONUNBUFFERED: "1" + FORCE_COLOR: "1" + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + TEST_MATRIX_OS: '["ubuntu-latest", "windows-latest", "macos-latest"]' + TEST_MATRIX_PYTHON: '["3.10", "3.14"]' + +jobs: + compute-test-matrix: + runs-on: ubuntu-slim + defaults: + run: + working-directory: . + outputs: + os: ${{ steps.set.outputs.os }} + python-version: ${{ steps.set.outputs.python-version }} + steps: + - id: set + run: | + echo 'os=${{ github.event_name == 'push' && '["ubuntu-latest"]' || env.TEST_MATRIX_OS }}' >> "$GITHUB_OUTPUT" + echo 'python-version=${{ github.event_name == 'push' && '["3.10"]' || env.TEST_MATRIX_PYTHON }}' >> "$GITHUB_OUTPUT" + + run: + name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} + needs: compute-test-matrix + permissions: + contents: write + pull-requests: write + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(needs.compute-test-matrix.outputs.os) }} + python-version: ${{ fromJSON(needs.compute-test-matrix.outputs.python-version) }} + + steps: + - name: Support longpaths + if: matrix.os == 'windows-latest' + working-directory: . + run: git config --system core.longpaths true + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Hatch + run: pip install hatch + + - name: Lint + if: matrix.python-version == '3.10' && runner.os == 'Linux' + run: hatch run fmt-check && hatch run test:types + + - name: Run unit tests + run: hatch run test:unit-cov-retry + + - name: Store unit tests coverage + if: matrix.python-version == '3.10' && runner.os == 'Linux' && github.event_name != 'schedule' + uses: py-cov-action/python-coverage-comment-action@7188638f871f721a365d644f505d1ff3df20d683 # v3.40 + with: + GITHUB_TOKEN: ${{ github.token }} + COVERAGE_PATH: integrations/ag2 + SUBPROJECT_ID: ag2 + COMMENT_ARTIFACT_NAME: coverage-comment-ag2 + MINIMUM_GREEN: 90 + MINIMUM_ORANGE: 60 + + - name: Run integration tests + run: hatch run test:integration-cov-append-retry + + - name: Store combined coverage + if: github.event_name == 'push' + uses: py-cov-action/python-coverage-comment-action@7188638f871f721a365d644f505d1ff3df20d683 # v3.40 + with: + GITHUB_TOKEN: ${{ github.token }} + COVERAGE_PATH: integrations/ag2 + SUBPROJECT_ID: ag2-combined + COMMENT_ARTIFACT_NAME: coverage-comment-ag2-combined + MINIMUM_GREEN: 90 + MINIMUM_ORANGE: 60 + + - name: Run unit tests with lowest direct dependencies + if: github.event_name != 'push' + run: | + hatch run uv pip compile pyproject.toml --resolution lowest-direct --output-file requirements_lowest_direct.txt + hatch -e test env run -- uv pip install -r requirements_lowest_direct.txt + hatch run test:unit + + - name: Nightly - run unit tests with Haystack main branch + if: github.event_name == 'schedule' + run: | + hatch env prune + hatch -e test env run -- uv pip install git+https://github.com/deepset-ai/haystack.git@main + hatch run test:unit + + notify-slack-on-failure: + needs: run + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-slim + steps: + - uses: deepset-ai/notify-slack-action@3cda73b77a148f16f703274198e7771340cf862b # v1 + with: + slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL_NOTIFICATIONS }} diff --git a/integrations/ag2/README.md b/integrations/ag2/README.md new file mode 100644 index 0000000000..b5334771c6 --- /dev/null +++ b/integrations/ag2/README.md @@ -0,0 +1,64 @@ +# ag2-haystack + +[![PyPI](https://img.shields.io/pypi/v/ag2-haystack.svg)](https://pypi.org/project/ag2-haystack/) + +An integration of [AG2](https://ag2.ai/) (formerly AutoGen) with [Haystack](https://haystack.deepset.ai/). + +AG2 is a multi-agent conversation framework with 500K+ monthly PyPI downloads, 4,300+ GitHub stars, and 400+ contributors. This integration brings AG2's powerful multi-agent orchestration capabilities into Haystack pipelines. + +## Installation + +```bash +pip install ag2-haystack +``` + +## Usage + +### Standalone + +```python +import os +from haystack_integrations.components.agents.ag2 import AG2Agent + +os.environ["OPENAI_API_KEY"] = "your-key" + +agent = AG2Agent( + model="gpt-4o-mini", + system_message="You are a helpful research assistant.", +) + +result = agent.run(query="What are the latest advances in RAG?") +print(result["reply"]) +``` + +### In a Haystack Pipeline + +```python +from haystack import Pipeline +from haystack_integrations.components.agents.ag2 import AG2Agent + +pipeline = Pipeline() +pipeline.add_component("agent", AG2Agent( + model="gpt-4o-mini", + system_message="Answer questions clearly and concisely.", +)) + +result = pipeline.run({"agent": {"query": "Explain retrieval-augmented generation."}}) +print(result["agent"]["reply"]) +``` + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `model` | str | `"gpt-4o-mini"` | LLM model name | +| `system_message` | str | `"You are a helpful AI assistant."` | System message for the assistant | +| `api_key_env_var` | str | `"OPENAI_API_KEY"` | Env var name for the API key | +| `api_type` | str | `"openai"` | API type (`"openai"`, `"bedrock"`, etc.) | +| `max_consecutive_auto_reply` | int | `10` | Max auto-replies | +| `human_input_mode` | str | `"NEVER"` | Human input mode | +| `code_execution` | bool | `False` | Enable code execution | + +## License + +Apache-2.0 — See [LICENSE](../../LICENSE) for details. diff --git a/integrations/ag2/examples/pipeline_example.py b/integrations/ag2/examples/pipeline_example.py new file mode 100644 index 0000000000..d7da262960 --- /dev/null +++ b/integrations/ag2/examples/pipeline_example.py @@ -0,0 +1,12 @@ +import os +from haystack import Pipeline +from haystack_integrations.components.agents.ag2 import AG2Agent + +pipeline = Pipeline() +pipeline.add_component("agent", AG2Agent( + model="gpt-4o-mini", + system_message="Answer questions clearly and concisely. Return TERMINATE when you have finished answering.", +)) + +result = pipeline.run({"agent": {"query": "Explain retrieval-augmented generation."}}) +print(result["agent"]["reply"]) diff --git a/integrations/ag2/pyproject.toml b/integrations/ag2/pyproject.toml new file mode 100644 index 0000000000..e7e3e422b3 --- /dev/null +++ b/integrations/ag2/pyproject.toml @@ -0,0 +1,91 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "ag2-haystack" +dynamic = ["version"] +description = "Haystack integration for AG2 multi-agent framework" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "deepset GmbH", email = "info@deepset.ai" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "haystack-ai", + "ag2[openai]>=0.11.4,<1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "ruff", + "mypy", +] + +[tool.hatch.version] +source = "vcs" +tag-pattern = "integrations/ag2-v(?P.*)" +fallback-version = "0.1.0" + +[tool.hatch.version.raw-options] +root = "../.." + +[tool.hatch.build.targets.wheel] +packages = ["src/haystack_integrations"] + +[tool.hatch.envs.default] +dependencies = [ + "haystack-ai", + "ag2[openai]>=0.11.4,<1.0", + "pytest", + "pytest-asyncio", +] + +[tool.hatch.envs.default.scripts] +test = "pytest tests/ {args}" +fmt = "ruff format src tests && ruff check --fix src tests" +fmt-check = "ruff format --check src tests && ruff check src tests" +lint = "ruff check src tests" +types = "mypy src" + +[tool.hatch.envs.test] +dependencies = [ + "pytest", + "pytest-asyncio", + "pytest-cov", + "pytest-rerunfailures", + "mypy", + "pip", +] + +[tool.hatch.envs.test.scripts] +unit = 'pytest -m "not integration" {args:tests}' +integration = 'pytest -m "integration" {args:tests}' +all = 'pytest {args:tests}' +unit-cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x -m "not integration" {args:tests}' +integration-cov-append-retry = 'pytest --cov=haystack_integrations --cov-append --reruns 3 --reruns-delay 30 -x -m "integration" {args:tests}' +types = "mypy -p haystack_integrations.components.agents.ag2 {args}" + +[tool.ruff] +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP"] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true diff --git a/integrations/ag2/src/haystack_integrations/components/agents/ag2/__init__.py b/integrations/ag2/src/haystack_integrations/components/agents/ag2/__init__.py new file mode 100644 index 0000000000..b9e759d8a3 --- /dev/null +++ b/integrations/ag2/src/haystack_integrations/components/agents/ag2/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2024-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +from haystack_integrations.components.agents.ag2.agent import AG2Agent + +__all__ = ["AG2Agent"] diff --git a/integrations/ag2/src/haystack_integrations/components/agents/ag2/agent.py b/integrations/ag2/src/haystack_integrations/components/agents/ag2/agent.py new file mode 100644 index 0000000000..b10406d189 --- /dev/null +++ b/integrations/ag2/src/haystack_integrations/components/agents/ag2/agent.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: 2024-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import logging +import os +from typing import Any + +from autogen import AssistantAgent, LLMConfig, UserProxyAgent +from haystack import component, default_from_dict, default_to_dict + +logger = logging.getLogger(__name__) + + +@component +class AG2Agent: + """ + A Haystack component that wraps AG2 (formerly AutoGen) multi-agent conversations. + + AG2 is a multi-agent conversation framework with 500K+ monthly PyPI downloads, + 4,300+ GitHub stars, and 400+ contributors. + + This component enables using AG2's powerful multi-agent orchestration within + Haystack pipelines. It creates an AssistantAgent and a UserProxyAgent, sends + the input query to the agents, and returns the conversation result. + + ### Usage example + + ```python + from haystack_integrations.components.agents.ag2 import AG2Agent + + agent = AG2Agent( + model="gpt-4o-mini", + system_message="You are a helpful research assistant.", + api_key_env_var="OPENAI_API_KEY", + ) + result = agent.run(query="What are the latest advances in RAG?") + print(result["reply"]) + ``` + + ### With Haystack Pipeline + + ```python + from haystack import Pipeline + from haystack_integrations.components.agents.ag2 import AG2Agent + + pipeline = Pipeline() + pipeline.add_component("ag2_agent", AG2Agent( + model="gpt-4o-mini", + system_message="You are a helpful assistant that answers questions.", + )) + + result = pipeline.run({"ag2_agent": {"query": "Explain RAG in simple terms."}}) + print(result["ag2_agent"]["reply"]) + ``` + """ + + def __init__( + self, + model: str = "gpt-4o-mini", + system_message: str = "You are a helpful AI assistant.", + api_key_env_var: str = "OPENAI_API_KEY", + api_type: str = "openai", + max_consecutive_auto_reply: int = 10, + human_input_mode: str = "NEVER", + assistant_name: str = "assistant", + user_proxy_name: str = "user_proxy", + code_execution: bool = False, + ): + """ + Initialize the AG2Agent component. + + :param model: The LLM model name to use (e.g., "gpt-4o-mini", "gpt-4o"). + :param system_message: The system message for the AG2 AssistantAgent. + :param api_key_env_var: Environment variable name containing the API key. + :param api_type: The API type for AG2 LLMConfig (e.g., "openai", "bedrock"). + :param max_consecutive_auto_reply: Max consecutive auto-replies. + :param human_input_mode: Human input mode ("NEVER", "ALWAYS", "TERMINATE"). + :param assistant_name: Name of the AG2 AssistantAgent. + :param user_proxy_name: Name of the AG2 UserProxyAgent. + :param code_execution: Whether to enable code execution in UserProxyAgent. + """ + self.model = model + self.system_message = system_message + self.api_key_env_var = api_key_env_var + self.api_type = api_type + self.max_consecutive_auto_reply = max_consecutive_auto_reply + self.human_input_mode = human_input_mode + self.assistant_name = assistant_name + self.user_proxy_name = user_proxy_name + self.code_execution = code_execution + + @component.output_types(reply=str, messages=list[dict[str, Any]]) + def run(self, query: str) -> dict[str, Any]: + """ + Run the AG2 multi-agent conversation with the given query. + + :param query: The input query to send to the AG2 agents. + :returns: A dictionary with: + - `reply`: The final assistant reply as a string. + - `messages`: The full conversation history as a list of message dicts. + """ + api_key = os.environ.get(self.api_key_env_var) + if not api_key: + msg = ( + f"Environment variable '{self.api_key_env_var}' is not set. " + "Please set it with your API key." + ) + raise ValueError(msg) + + # Create AG2 LLMConfig — positional argument, NOT keyword + llm_config = LLMConfig( + { + "model": self.model, + "api_key": api_key, + "api_type": self.api_type, + } + ) + + # Create AG2 agents — llm_config as parameter, NOT context manager + assistant = AssistantAgent( + name=self.assistant_name, + system_message=self.system_message, + llm_config=llm_config, + ) + + code_execution_config = {"use_docker": False} if self.code_execution else False + + user_proxy = UserProxyAgent( + name=self.user_proxy_name, + human_input_mode=self.human_input_mode, + max_consecutive_auto_reply=self.max_consecutive_auto_reply, + code_execution_config=code_execution_config, + is_termination_msg=lambda x: ( + x.get("content", "") and "TERMINATE" in x.get("content", "") + ), + ) + + # Execute conversation — run().process(), NOT initiate_chat() + user_proxy.run(assistant, message=query).process() + + # Extract messages + messages = assistant.chat_messages.get(user_proxy, []) + + # Get final reply + reply = "" + for msg in reversed(messages): + if msg.get("role") == "assistant" and msg.get("content"): + content = msg["content"] + if "TERMINATE" in content: + content = content.replace("TERMINATE", "").strip() + if content: + reply = content + break + + return {"reply": reply, "messages": messages} + + def to_dict(self) -> dict[str, Any]: + """Serialize this component to a dictionary.""" + return default_to_dict( + self, + model=self.model, + system_message=self.system_message, + api_key_env_var=self.api_key_env_var, + api_type=self.api_type, + max_consecutive_auto_reply=self.max_consecutive_auto_reply, + human_input_mode=self.human_input_mode, + assistant_name=self.assistant_name, + user_proxy_name=self.user_proxy_name, + code_execution=self.code_execution, + ) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "AG2Agent": + """Deserialize this component from a dictionary.""" + return default_from_dict(cls, data) diff --git a/integrations/ag2/tests/test_ag2_agent.py b/integrations/ag2/tests/test_ag2_agent.py new file mode 100644 index 0000000000..885bed85fe --- /dev/null +++ b/integrations/ag2/tests/test_ag2_agent.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: 2024-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from haystack_integrations.components.agents.ag2 import AG2Agent + + +class TestAG2AgentInit: + """Test AG2Agent initialization.""" + + def test_default_init(self): + agent = AG2Agent() + assert agent.model == "gpt-4o-mini" + assert agent.system_message == "You are a helpful AI assistant." + assert agent.api_key_env_var == "OPENAI_API_KEY" + assert agent.api_type == "openai" + assert agent.max_consecutive_auto_reply == 10 + assert agent.human_input_mode == "NEVER" + assert agent.assistant_name == "assistant" + assert agent.user_proxy_name == "user_proxy" + assert agent.code_execution is False + + def test_custom_init(self): + agent = AG2Agent( + model="gpt-4o", + system_message="Custom system message.", + api_key_env_var="CUSTOM_API_KEY", + api_type="openai", + max_consecutive_auto_reply=5, + human_input_mode="TERMINATE", + assistant_name="researcher", + user_proxy_name="coordinator", + code_execution=True, + ) + assert agent.model == "gpt-4o" + assert agent.system_message == "Custom system message." + assert agent.api_key_env_var == "CUSTOM_API_KEY" + assert agent.max_consecutive_auto_reply == 5 + assert agent.human_input_mode == "TERMINATE" + assert agent.assistant_name == "researcher" + assert agent.user_proxy_name == "coordinator" + assert agent.code_execution is True + + +class TestAG2AgentSerialization: + """Test AG2Agent serialization/deserialization.""" + + def test_to_dict(self): + agent = AG2Agent(model="gpt-4o", system_message="Test message.") + data = agent.to_dict() + assert ( + data["type"] == "haystack_integrations.components.agents.ag2.agent.AG2Agent" + ) + assert data["init_parameters"]["model"] == "gpt-4o" + assert data["init_parameters"]["system_message"] == "Test message." + + def test_from_dict(self): + agent = AG2Agent(model="gpt-4o", system_message="Test message.") + data = agent.to_dict() + restored = AG2Agent.from_dict(data) + assert restored.model == "gpt-4o" + assert restored.system_message == "Test message." + + def test_roundtrip_serialization(self): + agent = AG2Agent( + model="gpt-4o", + system_message="Roundtrip test.", + max_consecutive_auto_reply=5, + code_execution=True, + ) + data = agent.to_dict() + restored = AG2Agent.from_dict(data) + assert restored.model == agent.model + assert restored.system_message == agent.system_message + assert restored.max_consecutive_auto_reply == agent.max_consecutive_auto_reply + assert restored.code_execution == agent.code_execution + + +class TestAG2AgentRun: + """Test AG2Agent run method.""" + + def test_run_missing_api_key(self): + agent = AG2Agent(api_key_env_var="NONEXISTENT_KEY_12345") + with pytest.raises(ValueError, match="Environment variable"): + agent.run(query="test") + + @patch("haystack_integrations.components.agents.ag2.agent.UserProxyAgent") + @patch("haystack_integrations.components.agents.ag2.agent.AssistantAgent") + @patch("haystack_integrations.components.agents.ag2.agent.LLMConfig") + def test_run_returns_reply( + self, mock_llm_config, mock_assistant_cls, mock_user_proxy_cls + ): + os.environ["TEST_AG2_KEY"] = "test-key-123" + try: + # Setup mocks + mock_assistant = MagicMock() + mock_assistant_cls.return_value = mock_assistant + mock_user_proxy = MagicMock() + mock_user_proxy_cls.return_value = mock_user_proxy + mock_assistant.chat_messages = { + mock_user_proxy: [ + { + "role": "assistant", + "content": "Hello! How can I help? TERMINATE", + }, + ] + } + + agent = AG2Agent(api_key_env_var="TEST_AG2_KEY") + result = agent.run(query="Hello") + + assert "reply" in result + assert "messages" in result + assert result["reply"] == "Hello! How can I help?" + mock_user_proxy.run.assert_called_once() + finally: + del os.environ["TEST_AG2_KEY"] + + @patch("haystack_integrations.components.agents.ag2.agent.UserProxyAgent") + @patch("haystack_integrations.components.agents.ag2.agent.AssistantAgent") + @patch("haystack_integrations.components.agents.ag2.agent.LLMConfig") + def test_run_empty_messages( + self, mock_llm_config, mock_assistant_cls, mock_user_proxy_cls + ): + os.environ["TEST_AG2_KEY"] = "test-key-123" + try: + mock_assistant = MagicMock() + mock_assistant_cls.return_value = mock_assistant + mock_user_proxy = MagicMock() + mock_user_proxy_cls.return_value = mock_user_proxy + mock_assistant.chat_messages = {mock_user_proxy: []} + + agent = AG2Agent(api_key_env_var="TEST_AG2_KEY") + result = agent.run(query="Hello") + + assert result["reply"] == "" + assert result["messages"] == [] + finally: + del os.environ["TEST_AG2_KEY"] + + @patch("haystack_integrations.components.agents.ag2.agent.UserProxyAgent") + @patch("haystack_integrations.components.agents.ag2.agent.AssistantAgent") + @patch("haystack_integrations.components.agents.ag2.agent.LLMConfig") + def test_run_uses_correct_llm_config( + self, mock_llm_config, mock_assistant_cls, mock_user_proxy_cls + ): + os.environ["TEST_AG2_KEY"] = "test-key-123" + try: + mock_assistant = MagicMock() + mock_assistant_cls.return_value = mock_assistant + mock_user_proxy = MagicMock() + mock_user_proxy_cls.return_value = mock_user_proxy + mock_assistant.chat_messages = {mock_user_proxy: []} + + agent = AG2Agent( + model="gpt-4o", + api_key_env_var="TEST_AG2_KEY", + api_type="openai", + ) + agent.run(query="test") + + # Verify LLMConfig was called with positional dict argument + mock_llm_config.assert_called_once_with( + { + "model": "gpt-4o", + "api_key": "test-key-123", + "api_type": "openai", + } + ) + finally: + del os.environ["TEST_AG2_KEY"] diff --git a/releasenotes/notes/ag2-integration.yaml b/releasenotes/notes/ag2-integration.yaml new file mode 100644 index 0000000000..7ae6c91fe7 --- /dev/null +++ b/releasenotes/notes/ag2-integration.yaml @@ -0,0 +1,6 @@ +--- +enhancements: + - | + Added AG2 (formerly AutoGen) integration. The `AG2Agent` component + enables multi-agent conversation orchestration within Haystack pipelines. + Install with `pip install ag2-haystack`.