diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/base/__init__.py b/python/packages/autogen-agentchat/src/autogen_agentchat/base/__init__.py index 5815adc35fa9..dcb0a24c3e79 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/base/__init__.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/base/__init__.py @@ -2,7 +2,7 @@ from ._handoff import Handoff from ._task import TaskResult, TaskRunner from ._team import Team -from ._termination import TerminatedException, TerminationCondition +from ._termination import AndTerminationCondition, OrTerminationCondition, TerminatedException, TerminationCondition __all__ = [ "ChatAgent", @@ -10,6 +10,8 @@ "Team", "TerminatedException", "TerminationCondition", + "AndTerminationCondition", + "OrTerminationCondition", "TaskResult", "TaskRunner", "Handoff", diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/base/_termination.py b/python/packages/autogen-agentchat/src/autogen_agentchat/base/_termination.py index 8975c75aad12..dcefa5a04111 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/base/_termination.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/base/_termination.py @@ -2,13 +2,17 @@ from abc import ABC, abstractmethod from typing import List, Sequence +from autogen_core import Component, ComponentBase, ComponentModel +from pydantic import BaseModel +from typing_extensions import Self + from ..messages import AgentEvent, ChatMessage, StopMessage class TerminatedException(BaseException): ... -class TerminationCondition(ABC): +class TerminationCondition(ABC, ComponentBase[BaseModel]): """A stateful condition that determines when a conversation should be terminated. A termination condition is a callable that takes a sequence of ChatMessage objects @@ -43,6 +47,9 @@ async def main() -> None: asyncio.run(main()) """ + component_type = "termination" + # component_config_schema = BaseModel # type: ignore + @property @abstractmethod def terminated(self) -> bool: @@ -72,14 +79,22 @@ async def reset(self) -> None: def __and__(self, other: "TerminationCondition") -> "TerminationCondition": """Combine two termination conditions with an AND operation.""" - return _AndTerminationCondition(self, other) + return AndTerminationCondition(self, other) def __or__(self, other: "TerminationCondition") -> "TerminationCondition": """Combine two termination conditions with an OR operation.""" - return _OrTerminationCondition(self, other) + return OrTerminationCondition(self, other) + +class AndTerminationConditionConfig(BaseModel): + conditions: List[ComponentModel] + + +class AndTerminationCondition(TerminationCondition, Component[AndTerminationConditionConfig]): + component_config_schema = AndTerminationConditionConfig + component_type = "termination" + component_provider_override = "autogen_agentchat.base.AndTerminationCondition" -class _AndTerminationCondition(TerminationCondition): def __init__(self, *conditions: TerminationCondition) -> None: self._conditions = conditions self._stop_messages: List[StopMessage] = [] @@ -111,8 +126,27 @@ async def reset(self) -> None: await condition.reset() self._stop_messages.clear() + def _to_config(self) -> AndTerminationConditionConfig: + """Convert the AND termination condition to a config.""" + return AndTerminationConditionConfig(conditions=[condition.dump_component() for condition in self._conditions]) + + @classmethod + def _from_config(cls, config: AndTerminationConditionConfig) -> Self: + """Create an AND termination condition from a config.""" + conditions = [TerminationCondition.load_component(condition_model) for condition_model in config.conditions] + return cls(*conditions) + + +class OrTerminationConditionConfig(BaseModel): + conditions: List[ComponentModel] + """List of termination conditions where any one being satisfied is sufficient.""" + + +class OrTerminationCondition(TerminationCondition, Component[OrTerminationConditionConfig]): + component_config_schema = OrTerminationConditionConfig + component_type = "termination" + component_provider_override = "autogen_agentchat.base.OrTerminationCondition" -class _OrTerminationCondition(TerminationCondition): def __init__(self, *conditions: TerminationCondition) -> None: self._conditions = conditions @@ -133,3 +167,13 @@ async def __call__(self, messages: Sequence[AgentEvent | ChatMessage]) -> StopMe async def reset(self) -> None: for condition in self._conditions: await condition.reset() + + def _to_config(self) -> OrTerminationConditionConfig: + """Convert the OR termination condition to a config.""" + return OrTerminationConditionConfig(conditions=[condition.dump_component() for condition in self._conditions]) + + @classmethod + def _from_config(cls, config: OrTerminationConditionConfig) -> Self: + """Create an OR termination condition from a config.""" + conditions = [TerminationCondition.load_component(condition_model) for condition_model in config.conditions] + return cls(*conditions) diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/conditions/_terminations.py b/python/packages/autogen-agentchat/src/autogen_agentchat/conditions/_terminations.py index c472d7e323a5..d824815aeb1f 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/conditions/_terminations.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/conditions/_terminations.py @@ -1,13 +1,25 @@ import time from typing import List, Sequence +from autogen_core import Component +from pydantic import BaseModel +from typing_extensions import Self + from ..base import TerminatedException, TerminationCondition from ..messages import AgentEvent, ChatMessage, HandoffMessage, MultiModalMessage, StopMessage -class StopMessageTermination(TerminationCondition): +class StopMessageTerminationConfig(BaseModel): + pass + + +class StopMessageTermination(TerminationCondition, Component[StopMessageTerminationConfig]): """Terminate the conversation if a StopMessage is received.""" + component_type = "termination" + component_config_schema = StopMessageTerminationConfig + component_provider_override = "autogen_agentchat.conditions.StopMessageTermination" + def __init__(self) -> None: self._terminated = False @@ -27,14 +39,29 @@ async def __call__(self, messages: Sequence[AgentEvent | ChatMessage]) -> StopMe async def reset(self) -> None: self._terminated = False + def _to_config(self) -> StopMessageTerminationConfig: + return StopMessageTerminationConfig() + + @classmethod + def _from_config(cls, config: StopMessageTerminationConfig) -> Self: + return cls() + -class MaxMessageTermination(TerminationCondition): +class MaxMessageTerminationConfig(BaseModel): + max_messages: int + + +class MaxMessageTermination(TerminationCondition, Component[MaxMessageTerminationConfig]): """Terminate the conversation after a maximum number of messages have been exchanged. Args: max_messages: The maximum number of messages allowed in the conversation. """ + component_type = "termination" + component_config_schema = MaxMessageTerminationConfig + component_provider_override = "autogen_agentchat.conditions.MaxMessageTermination" + def __init__(self, max_messages: int) -> None: self._max_messages = max_messages self._message_count = 0 @@ -57,14 +84,30 @@ async def __call__(self, messages: Sequence[AgentEvent | ChatMessage]) -> StopMe async def reset(self) -> None: self._message_count = 0 + def _to_config(self) -> MaxMessageTerminationConfig: + return MaxMessageTerminationConfig(max_messages=self._max_messages) + + @classmethod + def _from_config(cls, config: MaxMessageTerminationConfig) -> Self: + return cls(max_messages=config.max_messages) + -class TextMentionTermination(TerminationCondition): +class TextMentionTerminationConfig(BaseModel): + text: str + + +class TextMentionTermination(TerminationCondition, Component[TextMentionTerminationConfig]): """Terminate the conversation if a specific text is mentioned. + Args: text: The text to look for in the messages. """ + component_type = "termination" + component_config_schema = TextMentionTerminationConfig + component_provider_override = "autogen_agentchat.conditions.TextMentionTermination" + def __init__(self, text: str) -> None: self._text = text self._terminated = False @@ -90,8 +133,21 @@ async def __call__(self, messages: Sequence[AgentEvent | ChatMessage]) -> StopMe async def reset(self) -> None: self._terminated = False + def _to_config(self) -> TextMentionTerminationConfig: + return TextMentionTerminationConfig(text=self._text) + + @classmethod + def _from_config(cls, config: TextMentionTerminationConfig) -> Self: + return cls(text=config.text) + -class TokenUsageTermination(TerminationCondition): +class TokenUsageTerminationConfig(BaseModel): + max_total_token: int | None + max_prompt_token: int | None + max_completion_token: int | None + + +class TokenUsageTermination(TerminationCondition, Component[TokenUsageTerminationConfig]): """Terminate the conversation if a token usage limit is reached. Args: @@ -103,6 +159,10 @@ class TokenUsageTermination(TerminationCondition): ValueError: If none of max_total_token, max_prompt_token, or max_completion_token is provided. """ + component_type = "termination" + component_config_schema = TokenUsageTerminationConfig + component_provider_override = "autogen_agentchat.conditions.TokenUsageTermination" + def __init__( self, max_total_token: int | None = None, @@ -146,8 +206,27 @@ async def reset(self) -> None: self._prompt_token_count = 0 self._completion_token_count = 0 + def _to_config(self) -> TokenUsageTerminationConfig: + return TokenUsageTerminationConfig( + max_total_token=self._max_total_token, + max_prompt_token=self._max_prompt_token, + max_completion_token=self._max_completion_token, + ) + + @classmethod + def _from_config(cls, config: TokenUsageTerminationConfig) -> Self: + return cls( + max_total_token=config.max_total_token, + max_prompt_token=config.max_prompt_token, + max_completion_token=config.max_completion_token, + ) + + +class HandoffTerminationConfig(BaseModel): + target: str -class HandoffTermination(TerminationCondition): + +class HandoffTermination(TerminationCondition, Component[HandoffTerminationConfig]): """Terminate the conversation if a :class:`~autogen_agentchat.messages.HandoffMessage` with the given target is received. @@ -155,6 +234,10 @@ class HandoffTermination(TerminationCondition): target (str): The target of the handoff message. """ + component_type = "termination" + component_config_schema = HandoffTerminationConfig + component_provider_override = "autogen_agentchat.conditions.HandoffTermination" + def __init__(self, target: str) -> None: self._terminated = False self._target = target @@ -177,14 +260,29 @@ async def __call__(self, messages: Sequence[AgentEvent | ChatMessage]) -> StopMe async def reset(self) -> None: self._terminated = False + def _to_config(self) -> HandoffTerminationConfig: + return HandoffTerminationConfig(target=self._target) + + @classmethod + def _from_config(cls, config: HandoffTerminationConfig) -> Self: + return cls(target=config.target) -class TimeoutTermination(TerminationCondition): + +class TimeoutTerminationConfig(BaseModel): + timeout_seconds: float + + +class TimeoutTermination(TerminationCondition, Component[TimeoutTerminationConfig]): """Terminate the conversation after a specified duration has passed. Args: timeout_seconds: The maximum duration in seconds before terminating the conversation. """ + component_type = "termination" + component_config_schema = TimeoutTerminationConfig + component_provider_override = "autogen_agentchat.conditions.TimeoutTermination" + def __init__(self, timeout_seconds: float) -> None: self._timeout_seconds = timeout_seconds self._start_time = time.monotonic() @@ -209,8 +307,19 @@ async def reset(self) -> None: self._start_time = time.monotonic() self._terminated = False + def _to_config(self) -> TimeoutTerminationConfig: + return TimeoutTerminationConfig(timeout_seconds=self._timeout_seconds) + + @classmethod + def _from_config(cls, config: TimeoutTerminationConfig) -> Self: + return cls(timeout_seconds=config.timeout_seconds) -class ExternalTermination(TerminationCondition): + +class ExternalTerminationConfig(BaseModel): + pass + + +class ExternalTermination(TerminationCondition, Component[ExternalTerminationConfig]): """A termination condition that is externally controlled by calling the :meth:`set` method. @@ -230,6 +339,10 @@ class ExternalTermination(TerminationCondition): """ + component_type = "termination" + component_config_schema = ExternalTerminationConfig + component_provider_override = "autogen_agentchat.conditions.ExternalTermination" + def __init__(self) -> None: self._terminated = False self._setted = False @@ -254,8 +367,19 @@ async def reset(self) -> None: self._terminated = False self._setted = False + def _to_config(self) -> ExternalTerminationConfig: + return ExternalTerminationConfig() + + @classmethod + def _from_config(cls, config: ExternalTerminationConfig) -> Self: + return cls() + -class SourceMatchTermination(TerminationCondition): +class SourceMatchTerminationConfig(BaseModel): + sources: List[str] + + +class SourceMatchTermination(TerminationCondition, Component[SourceMatchTerminationConfig]): """Terminate the conversation after a specific source responds. Args: @@ -265,6 +389,10 @@ class SourceMatchTermination(TerminationCondition): TerminatedException: If the termination condition has already been reached. """ + component_type = "termination" + component_config_schema = SourceMatchTerminationConfig + component_provider_override = "autogen_agentchat.conditions.SourceMatchTermination" + def __init__(self, sources: List[str]) -> None: self._sources = sources self._terminated = False @@ -286,3 +414,10 @@ async def __call__(self, messages: Sequence[AgentEvent | ChatMessage]) -> StopMe async def reset(self) -> None: self._terminated = False + + def _to_config(self) -> SourceMatchTerminationConfig: + return SourceMatchTerminationConfig(sources=self._sources) + + @classmethod + def _from_config(cls, config: SourceMatchTerminationConfig) -> Self: + return cls(sources=config.sources) diff --git a/python/packages/autogen-agentchat/tests/test_declarative_components.py b/python/packages/autogen-agentchat/tests/test_declarative_components.py new file mode 100644 index 000000000000..35cf54f86416 --- /dev/null +++ b/python/packages/autogen-agentchat/tests/test_declarative_components.py @@ -0,0 +1,94 @@ +import pytest +from autogen_agentchat.base import AndTerminationCondition +from autogen_agentchat.conditions import ( + ExternalTermination, + HandoffTermination, + MaxMessageTermination, + SourceMatchTermination, + StopMessageTermination, + TextMentionTermination, + TimeoutTermination, + TokenUsageTermination, +) +from autogen_core import ComponentLoader, ComponentModel + + +@pytest.mark.asyncio +async def test_termination_declarative() -> None: + """Test that termination conditions can be declared and serialized properly.""" + # Create basic termination conditions + max_term = MaxMessageTermination(5) + stop_term = StopMessageTermination() + text_term = TextMentionTermination("stop") + token_term = TokenUsageTermination(max_total_token=100, max_prompt_token=50, max_completion_token=100) + handoff_term = HandoffTermination(target="human") + timeout_term = TimeoutTermination(timeout_seconds=30) + external_term = ExternalTermination() + source_term = SourceMatchTermination(sources=["human"]) + + # Test basic serialization + max_config = max_term.dump_component() + assert isinstance(max_config, ComponentModel) + assert max_config.provider == "autogen_agentchat.conditions.MaxMessageTermination" + assert max_config.config.get("max_messages") == 5 + + # Test serialization of new conditions + text_config = text_term.dump_component() + assert text_config.provider == "autogen_agentchat.conditions.TextMentionTermination" + assert text_config.config.get("text") == "stop" + + token_config = token_term.dump_component() + assert token_config.provider == "autogen_agentchat.conditions.TokenUsageTermination" + assert token_config.config.get("max_total_token") == 100 + + handoff_config = handoff_term.dump_component() + assert handoff_config.provider == "autogen_agentchat.conditions.HandoffTermination" + assert handoff_config.config.get("target") == "human" + + timeout_config = timeout_term.dump_component() + assert timeout_config.provider == "autogen_agentchat.conditions.TimeoutTermination" + assert timeout_config.config.get("timeout_seconds") == 30 + + external_config = external_term.dump_component() + assert external_config.provider == "autogen_agentchat.conditions.ExternalTermination" + + source_config = source_term.dump_component() + assert source_config.provider == "autogen_agentchat.conditions.SourceMatchTermination" + assert source_config.config.get("sources") == ["human"] + + # Test basic deserialization + loaded_max = ComponentLoader.load_component(max_config, MaxMessageTermination) + assert isinstance(loaded_max, MaxMessageTermination) + + # Test deserialization of new conditions + loaded_text = ComponentLoader.load_component(text_config, TextMentionTermination) + assert isinstance(loaded_text, TextMentionTermination) + + loaded_token = ComponentLoader.load_component(token_config, TokenUsageTermination) + assert isinstance(loaded_token, TokenUsageTermination) + + loaded_handoff = ComponentLoader.load_component(handoff_config, HandoffTermination) + assert isinstance(loaded_handoff, HandoffTermination) + + loaded_timeout = ComponentLoader.load_component(timeout_config, TimeoutTermination) + assert isinstance(loaded_timeout, TimeoutTermination) + + loaded_external = ComponentLoader.load_component(external_config, ExternalTermination) + assert isinstance(loaded_external, ExternalTermination) + + loaded_source = ComponentLoader.load_component(source_config, SourceMatchTermination) + assert isinstance(loaded_source, SourceMatchTermination) + + # Test composition with new conditions + composite_term = (max_term | stop_term) & (token_term | handoff_term) + composite_config = composite_term.dump_component() + + assert composite_config.provider == "autogen_agentchat.base.AndTerminationCondition" + conditions = composite_config.config["conditions"] + assert len(conditions) == 2 + assert conditions[0]["provider"] == "autogen_agentchat.base.OrTerminationCondition" + assert conditions[1]["provider"] == "autogen_agentchat.base.OrTerminationCondition" + + # Test loading complex composition + loaded_composite = ComponentLoader.load_component(composite_config) + assert isinstance(loaded_composite, AndTerminationCondition) diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/index.md b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/index.md index 9754bd8ca5ed..7a764c4c7340 100644 --- a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/index.md +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/index.md @@ -91,6 +91,7 @@ tutorial/human-in-the-loop tutorial/termination tutorial/custom-agents tutorial/state +tutorial/declarative ``` ```{toctree} diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/declarative.ipynb b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/declarative.ipynb new file mode 100644 index 000000000000..274135c1155c --- /dev/null +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/declarative.ipynb @@ -0,0 +1,119 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Declarative Components \n", + "\n", + "AutoGen provides a declarative {py:class}`~autogen_core.Component` configuration class that defines behaviours for declarative import/export. This is useful for debugging, visualizing, and even for sharing your work with others. In this notebook, we will demonstrate how to export a declarative representation of a multiagent team in the form of a JSON file. \n", + "\n", + "\n", + "```{note}\n", + "This is work in progress\n", + "``` \n", + "\n", + "We will be implementing declarative support for the following components:\n", + "\n", + "- Termination conditions ✔️\n", + "- Tools \n", + "- Agents \n", + "- Teams \n", + "\n", + "\n", + "### Termination Condition Example \n", + "\n", + "In the example below, we will define termination conditions (a part of an agent team) in python, export this to a dictionary/json and also demonstrate how the termination condition object can be loaded from the dictionary/json. \n", + " " + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from autogen_agentchat.conditions import MaxMessageTermination, StopMessageTermination\n", + "\n", + "max_termination = MaxMessageTermination(5)\n", + "stop_termination = StopMessageTermination()" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "provider='autogen_agentchat.conditions.MaxMessageTermination' component_type='termination' version=1 component_version=1 description=None config={'max_messages': 5}\n" + ] + } + ], + "source": [ + "print(max_termination.dump_component())" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'provider': 'autogen_agentchat.conditions.MaxMessageTermination', 'component_type': 'termination', 'version': 1, 'component_version': 1, 'description': None, 'config': {'max_messages': 5}}\n" + ] + } + ], + "source": [ + "print(max_termination.dump_component().model_dump())" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "ComponentModel(provider='autogen_agentchat.base.OrTerminationCondition', component_type='termination', version=1, component_version=1, description=None, config={'conditions': [{'provider': 'autogen_agentchat.conditions.MaxMessageTermination', 'component_type': 'termination', 'version': 1, 'component_version': 1, 'config': {'max_messages': 5}}, {'provider': 'autogen_agentchat.conditions.StopMessageTermination', 'component_type': 'termination', 'version': 1, 'component_version': 1, 'config': {}}]})" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "or_termination = max_termination | stop_termination\n", + "or_termination.dump_component()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +}