diff --git a/.gitignore b/.gitignore index 40904d560..bc0c812dd 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,7 @@ letsencrypt/ # dev env .env + +# Pants build system +/.pants.d/ +/dist/ diff --git a/full_requirements.txt b/full_requirements.txt index d618f2e67..51577440b 100644 --- a/full_requirements.txt +++ b/full_requirements.txt @@ -1,9 +1,9 @@ # Drakkar-Software full requirements -OctoBot-Commons[full]==1.10.4 -OctoBot-Trading[full]==2.5.0 -OctoBot-Evaluators[full]==1.10.0 +OctoBot-Commons[full]==1.10.6 +OctoBot-Trading[full]==2.5.1 +OctoBot-Evaluators[full]==1.10.1 OctoBot-Tentacles-Manager[full]==2.10.0 -OctoBot-Services[full]==1.7.0 +OctoBot-Services[full]==1.7.2 OctoBot-Backtesting[full]==1.10.0 ## Others diff --git a/octobot/cli.py b/octobot/cli.py index 00829d9b3..ed48c849c 100644 --- a/octobot/cli.py +++ b/octobot/cli.py @@ -38,6 +38,14 @@ # make tentacles importable sys.path.append(os.path.dirname(sys.executable)) + + # Add packages/agents to Python path for octobot_agents + # This allows octobot_agents package to be imported + cli_dir = os.path.dirname(os.path.abspath(__file__)) + octobot_dir = os.path.dirname(cli_dir) + packages_agents_path = os.path.join(octobot_dir, 'packages', 'agents') + if os.path.isdir(packages_agents_path) and packages_agents_path not in sys.path: + sys.path.insert(0, packages_agents_path) import octobot.octobot as octobot_class import octobot.commands as commands diff --git a/octobot/logger.py b/octobot/logger.py index ad64fc14d..22ff3c297 100644 --- a/octobot/logger.py +++ b/octobot/logger.py @@ -400,6 +400,8 @@ async def matrix_callback( evaluator_type, eval_note, eval_note_type, + eval_note_description, + eval_note_metadata, exchange_name, cryptocurrency, symbol, @@ -410,6 +412,7 @@ async def matrix_callback( f"EVALUATOR = {evaluator_name} || EVALUATOR_TYPE = {evaluator_type} || " f"CRYPTOCURRENCY = {cryptocurrency} || SYMBOL = {symbol} || TF = {time_frame} " f"|| NOTE = {eval_note} [MATRIX id = {matrix_id}] " + f"|| DESCRIPTION = {eval_note_description}" if eval_note_description else "" ) diff --git a/packages/agents/BUILD b/packages/agents/BUILD new file mode 100644 index 000000000..974388666 --- /dev/null +++ b/packages/agents/BUILD @@ -0,0 +1,8 @@ +# Pants BUILD file for octobot_agents package + +python_sources( + name="lib", + sources=["octobot_agents/**/*.py"], + # External dependencies (async_channel, octobot_commons, pydantic) + # will be resolved from requirements.txt or lockfile via Pants dependency inference +) diff --git a/packages/agents/README.md b/packages/agents/README.md new file mode 100644 index 000000000..a702ad5d7 --- /dev/null +++ b/packages/agents/README.md @@ -0,0 +1 @@ +# OctoBot Agents diff --git a/packages/agents/octobot_agents/__init__.py b/packages/agents/octobot_agents/__init__.py new file mode 100644 index 000000000..ea91c294d --- /dev/null +++ b/packages/agents/octobot_agents/__init__.py @@ -0,0 +1,57 @@ +# This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +from octobot_agents import channel +from octobot_agents import team + +# Base agent channel classes +from octobot_agents.channel import ( + AbstractAgentChannel, + AbstractAgentChannelProducer, + AbstractAgentChannelConsumer, +) + +# AI agent channel classes (with LLM capabilities) +from octobot_agents.channel import ( + AbstractAIAgentChannelProducer, + AbstractAIAgentChannelConsumer, +) + +# Team channel classes +from octobot_agents.team.team import ( + AbstractAgentTeamChannel, + AbstractAgentTeamChannelProducer, + AbstractAgentTeamChannelConsumer, + AbstractSyncAgentTeamChannelProducer, + AbstractLiveAgentTeamChannelProducer, +) + + +__all__ = [ + # Base classes + "AbstractAgentChannel", + "AbstractAgentChannelProducer", + "AbstractAgentChannelConsumer", + # AI agent classes + "AbstractAIAgentChannelProducer", + "AbstractAIAgentChannelConsumer", + # Team classes + "AbstractAgentTeamChannel", + "AbstractAgentTeamChannelProducer", + "AbstractAgentTeamChannelConsumer", + "AbstractSyncAgentTeamChannelProducer", + "AbstractLiveAgentTeamChannelProducer", +] diff --git a/packages/agents/octobot_agents/channel.py b/packages/agents/octobot_agents/channel.py new file mode 100644 index 000000000..1b00ed198 --- /dev/null +++ b/packages/agents/octobot_agents/channel.py @@ -0,0 +1,476 @@ +# This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . +""" +Abstract agent channel classes for pub/sub communication between agents and consumers. + +Follows the same pattern as AbstractServiceFeedChannel in octobot_services. +Agent tentacles should inherit from these abstract classes and define their own +Channel, Producer, and Consumer classes. + +Hierarchy: +- Base classes: AbstractAgentChannelConsumer, AbstractAgentChannelProducer, AbstractAgentChannel +- AI-specific: AbstractAIAgentChannelConsumer, AbstractAIAgentChannelProducer +""" +import abc +import json +import typing + +import async_channel.enums as channel_enums +import async_channel.constants as channel_constants +import async_channel.channels as channels +import async_channel.consumer as consumer +import async_channel.producer as producer + +import octobot_commons.logging as logging + + +# ============================================================================= +# Base Agent Channel Classes (Simple, like ServiceFeed pattern) +# ============================================================================= + + +class AbstractAgentChannelConsumer(consumer.Consumer): + """ + Abstract consumer for agent channels. + + Consumers receive agent execution results pushed by producers. + """ + __metaclass__ = abc.ABCMeta + + +class AbstractAgentChannelProducer(producer.Producer): + """ + Abstract producer for agent channels. + + Simple base class following the service feed pattern. + Producers execute agent logic and push results to consumers. + """ + __metaclass__ = abc.ABCMeta + + +class AbstractAgentChannel(channels.Channel): + """ + Abstract channel for agents with agent_name and agent_id filtering. + + Agent tentacles should inherit from this class and define their own channel. + Example: + class TechnicalAnalysisAIAgentChannel(AbstractAgentChannel): + OUTPUT_SCHEMA = TechnicalAnalysisOutput + """ + __metaclass__ = abc.ABCMeta + + PRODUCER_CLASS = AbstractAgentChannelProducer + CONSUMER_CLASS = AbstractAgentChannelConsumer + + # Keys for agent/team data structures + AGENT_NAME_KEY = "agent_name" + AGENT_ID_KEY = "agent_id" + TEAM_NAME_KEY = "team_name" + TEAM_ID_KEY = "team_id" + RESULT_KEY = "result" + + # Output schema - override in subclasses with Pydantic model class + OUTPUT_SCHEMA: typing.Optional[typing.Type] = None + + DEFAULT_PRIORITY_LEVEL = channel_enums.ChannelConsumerPriorityLevels.HIGH.value + + def __init__( + self, + team_name: typing.Optional[str] = None, + team_id: typing.Optional[str] = None, + ): + """ + Initialize the agent channel. + + Args: + team_name: Optional name of the team this channel belongs to. + team_id: Optional unique identifier for the team instance. + """ + super().__init__() + self.team_name = team_name + self.team_id = team_id + self.logger = logging.get_logger(self.__class__.__name__) + + @classmethod + def get_output_schema(cls) -> typing.Optional[typing.Type]: + """ + Get the Pydantic model class for this channel's output. + + Override OUTPUT_SCHEMA in subclasses to define the expected output format. + This schema is used by _call_llm() as the default response_schema. + + Returns: + The Pydantic BaseModel class, or None if not defined. + """ + return cls.OUTPUT_SCHEMA + + async def new_consumer( + self, + callback: typing.Callable = None, + consumer_instance: "AbstractAgentChannelConsumer" = None, + size: int = 0, + priority_level: int = DEFAULT_PRIORITY_LEVEL, + agent_name: str = channel_constants.CHANNEL_WILDCARD, + agent_id: str = channel_constants.CHANNEL_WILDCARD, + **kwargs, + ) -> "AbstractAgentChannelConsumer": + """ + Create a new consumer for this channel. + + Args: + callback: Method to call when consuming queue data. + consumer_instance: Existing consumer instance to use. + size: Queue size (0 = unlimited). + priority_level: Consumer priority level. + agent_name: Filter by agent name (wildcard = all agents). + agent_id: Filter by agent id (wildcard = all instances). + **kwargs: Additional arguments. + + Returns: + The created consumer instance. + """ + consumer_inst = ( + consumer_instance + if consumer_instance + else self.CONSUMER_CLASS(callback, size=size, priority_level=priority_level) + ) + await self._add_new_consumer_and_run( + consumer_inst, + agent_name=agent_name, + agent_id=agent_id, + **kwargs, + ) + await self._check_producers_state() + return consumer_inst + + def get_filtered_consumers( + self, + agent_name: str = channel_constants.CHANNEL_WILDCARD, + agent_id: str = channel_constants.CHANNEL_WILDCARD, + ) -> list: + """ + Get consumers matching the specified filters. + + Args: + agent_name: Filter by agent name. + agent_id: Filter by agent id. + + Returns: + List of matching consumer instances. + """ + return self.get_consumer_from_filters({ + self.AGENT_NAME_KEY: agent_name, + self.AGENT_ID_KEY: agent_id, + }) + + async def _add_new_consumer_and_run( + self, + consumer_inst: "AbstractAgentChannelConsumer", + agent_name: str = channel_constants.CHANNEL_WILDCARD, + agent_id: str = channel_constants.CHANNEL_WILDCARD, + **kwargs, + ) -> None: + """ + Add consumer to the channel and start it. + + Args: + consumer_inst: The consumer instance to add. + agent_name: Agent name filter for this consumer. + agent_id: Agent id filter for this consumer. + """ + self.add_new_consumer( + consumer_inst, + { + self.AGENT_NAME_KEY: agent_name, + self.AGENT_ID_KEY: agent_id, + }, + ) + await consumer_inst.run(with_task=not self.is_synchronized) + self.logger.debug( + f"Consumer started for agent_name={agent_name}, agent_id={agent_id}: {consumer_inst}" + ) + + +# ============================================================================= +# AI Agent Channel Classes (with LLM capabilities) +# ============================================================================= + + +class AbstractAIAgentChannelConsumer(AbstractAgentChannelConsumer): + """ + Consumer for AI agent channels with input aggregation support. + + Can aggregate inputs from multiple producers before triggering the associated producer. + Useful for agents that need to wait for multiple upstream agents to complete. + """ + __metaclass__ = abc.ABCMeta + + def __init__( + self, + callback: typing.Callable = None, + size: int = 0, + priority_level: int = AbstractAgentChannel.DEFAULT_PRIORITY_LEVEL, + expected_inputs: int = 1, + ): + """ + Initialize the AI agent consumer. + + Args: + callback: Method to call when consuming queue data. + size: Queue size (0 = unlimited). + priority_level: Consumer priority level. + expected_inputs: Number of inputs to aggregate before triggering. + """ + super().__init__(callback, size=size, priority_level=priority_level) + self.expected_inputs = expected_inputs + self.received_inputs: typing.Dict[str, typing.Any] = {} + self.producer: typing.Optional["AbstractAIAgentChannelProducer"] = None + + def set_producer(self, producer_instance: "AbstractAIAgentChannelProducer") -> None: + """Set the producer to trigger when inputs are ready.""" + self.producer = producer_instance + + def is_ready(self) -> bool: + """Check if all expected inputs have been received.""" + return len(self.received_inputs) >= self.expected_inputs + + def add_input(self, source_name: str, data: typing.Any) -> None: + """ + Add input data from a source. + + Args: + source_name: Name of the source agent. + data: The data received from the source. + """ + self.received_inputs[source_name] = data + + def get_aggregated_inputs(self) -> typing.Dict[str, typing.Any]: + """Get all received inputs.""" + return self.received_inputs.copy() + + def clear_inputs(self) -> None: + """Clear all received inputs.""" + self.received_inputs.clear() + + +class AbstractAIAgentChannelProducer(AbstractAgentChannelProducer, abc.ABC): + """ + Producer for AI agents with LLM calling capabilities. + + Follows the same pattern as AbstractServiceFeed inheriting from + AbstractServiceFeedChannelProducer. + + Provides common functionality for LLM calling, prompt management, + retry logic, and data formatting. Subclasses should implement + _get_default_prompt() and execute() methods. + """ + + # Class-level defaults (can be overridden by subclasses) + AGENT_NAME: str = "AbstractAIAgent" + AGENT_VERSION: str = "1.0.0" + DEFAULT_MODEL: typing.Optional[str] = None + DEFAULT_MAX_TOKENS: int = 10000 + DEFAULT_TEMPERATURE: float = 0.3 + MAX_RETRIES: int = 3 + + # Override in subclasses with dedicated channel and consumer classes + AGENT_CHANNEL: typing.Optional[typing.Type[AbstractAgentChannel]] = None + AGENT_CONSUMER: typing.Optional[typing.Type[AbstractAIAgentChannelConsumer]] = None + + def __init__( + self, + channel: typing.Optional[AbstractAgentChannel], + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + ): + """ + Initialize the AI agent producer. + + Args: + channel: The channel this producer is registered to. + model: LLM model to use. Defaults to DEFAULT_MODEL. + max_tokens: Maximum tokens for response. Defaults to DEFAULT_MAX_TOKENS. + temperature: Temperature for LLM randomness. Defaults to DEFAULT_TEMPERATURE. + """ + super().__init__(channel) + self.model = model or self.DEFAULT_MODEL + self.max_tokens = max_tokens or self.DEFAULT_MAX_TOKENS + self.temperature = temperature or self.DEFAULT_TEMPERATURE + self._custom_prompt: typing.Optional[str] = None + self.ai_service: typing.Any = None + self.logger = logging.get_logger(f"{self.__class__.__name__}") + + @property + def prompt(self) -> str: + """Get the agent's prompt, allowing override via config.""" + return self._custom_prompt or self._get_default_prompt() + + @prompt.setter + def prompt(self, value: str) -> None: + """Allow custom prompt override.""" + self._custom_prompt = value + + @abc.abstractmethod + def _get_default_prompt(self) -> str: + """ + Return the default prompt for this agent type. + + Subclasses must implement this to provide their system prompt. + + Returns: + The default system prompt string. + """ + raise NotImplementedError("_get_default_prompt not implemented") + + @abc.abstractmethod + async def execute(self, input_data: typing.Any, ai_service: typing.Any) -> typing.Any: + """ + Execute the agent's primary function. + + Args: + input_data: The input data for the agent to process. + ai_service: The AI service instance (AbstractAIService). + + Returns: + The agent's output, type depends on the specific agent. + """ + raise NotImplementedError("execute not implemented") + + async def push( + self, + result: typing.Any, + agent_name: typing.Optional[str] = None, + agent_id: typing.Optional[str] = None, + ) -> None: + """ + Push a result to filtered consumers. + + Args: + result: The result data to push. + agent_name: Agent name for filtering (defaults to AGENT_NAME). + agent_id: Agent id for filtering. + """ + if self.channel is None: + return + await self.perform( + result, + agent_name=agent_name or self.AGENT_NAME, + agent_id=agent_id or "", + ) + + async def perform( + self, + result: typing.Any, + agent_name: str, + agent_id: str, + ) -> None: + """ + Send result to matching consumers. + + Args: + result: The result data to send. + agent_name: Agent name for consumer filtering. + agent_id: Agent id for consumer filtering. + """ + if self.channel is None: + return + for consumer_instance in self.channel.get_filtered_consumers( + agent_name=agent_name, + agent_id=agent_id, + ): + await consumer_instance.queue.put({ + "agent_name": agent_name, + "agent_id": agent_id, + "result": result, + }) + + async def _call_llm( + self, + messages: list, + llm_service: typing.Any, + json_output: bool = True, + response_schema: typing.Optional[typing.Any] = None, + ) -> typing.Any: + """ + Common LLM calling method with error handling and automatic retries. + + Args: + messages: List of message dicts with 'role' and 'content'. + llm_service: The LLM service instance. + json_output: Whether to parse response as JSON. + response_schema: Optional Pydantic model or JSON schema for structured output. + If None, uses the channel's OUTPUT_SCHEMA as default. + + Returns: + Parsed JSON dict or raw string response. + + Raises: + Exception: If all retries are exhausted. + """ + # Use channel's output schema as default if not explicitly provided + effective_schema = response_schema + if effective_schema is None and self.AGENT_CHANNEL is not None: + effective_schema = self.AGENT_CHANNEL.get_output_schema() + + last_exception = None + + for attempt in range(1, self.MAX_RETRIES + 1): + try: + response = await llm_service.get_completion( + messages=messages, + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + json_output=json_output, + response_schema=effective_schema, + ) + if json_output: + return json.loads(response.strip()) + return response.strip() + except (json.JSONDecodeError, ValueError, KeyError, AttributeError) as e: + last_exception = e + error_details = str(e) + if attempt < self.MAX_RETRIES: + self.logger.warning( + f"LLM call failed on attempt {attempt}/{self.MAX_RETRIES} " + f"for agent {self.AGENT_NAME}: {error_details}. Retrying..." + ) + else: + self.logger.error( + f"LLM call failed on final attempt {attempt}/{self.MAX_RETRIES} " + f"for agent {self.AGENT_NAME}: {error_details}" + ) + + # All retries exhausted + raise Exception( + f"LLM call failed for agent {self.AGENT_NAME} after {self.MAX_RETRIES} retries: {str(last_exception)}" + ) + + def format_data(self, data: typing.Any, default_message: str = "No data available.") -> str: + """ + Format data for inclusion in prompts. + + Args: + data: Data to format (dict, list, or other JSON-serializable type). + default_message: Message to return if data is empty/None. + + Returns: + JSON-formatted string or default message. + """ + if not data: + return default_message + return json.dumps(data, indent=2, default=str) diff --git a/packages/agents/octobot_agents/team/__init__.py b/packages/agents/octobot_agents/team/__init__.py new file mode 100644 index 000000000..640f38804 --- /dev/null +++ b/packages/agents/octobot_agents/team/__init__.py @@ -0,0 +1,64 @@ +# This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +from octobot_agents.team.team import ( + AbstractAgentTeamChannel, + AbstractAgentTeamChannelProducer, + AbstractAgentTeamChannelConsumer, + AbstractSyncAgentTeamChannelProducer, + AbstractLiveAgentTeamChannelProducer, +) + +from octobot_agents.team.team_manager import ( + AbstractTeamManagerAgent, + DefaultTeamManagerAgentProducer, + AITeamManagerAgentProducer, + DefaultTeamManagerAgentChannel, + DefaultTeamManagerAgentConsumer, + AITeamManagerAgentChannel, + AITeamManagerAgentConsumer, + ExecutionPlan, + ExecutionStep, + AgentInstruction, + MODIFICATION_ADDITIONAL_INSTRUCTIONS, + MODIFICATION_CUSTOM_PROMPT, + MODIFICATION_EXECUTION_HINTS, +) + +__all__ = [ + # Team classes + "AbstractAgentTeamChannel", + "AbstractAgentTeamChannelProducer", + "AbstractAgentTeamChannelConsumer", + "AbstractSyncAgentTeamChannelProducer", + "AbstractLiveAgentTeamChannelProducer", + # Manager classes + "AbstractTeamManagerAgent", + "DefaultTeamManagerAgentProducer", + "AITeamManagerAgentProducer", + "DefaultTeamManagerAgentChannel", + "DefaultTeamManagerAgentConsumer", + "AITeamManagerAgentChannel", + "AITeamManagerAgentConsumer", + # Models + "ExecutionPlan", + "ExecutionStep", + "AgentInstruction", + # Constants + "MODIFICATION_ADDITIONAL_INSTRUCTIONS", + "MODIFICATION_CUSTOM_PROMPT", + "MODIFICATION_EXECUTION_HINTS", +] diff --git a/packages/agents/octobot_agents/team/team.py b/packages/agents/octobot_agents/team/team.py new file mode 100644 index 000000000..8b56e6298 --- /dev/null +++ b/packages/agents/octobot_agents/team/team.py @@ -0,0 +1,692 @@ +# This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . +""" +Abstract agent team channel classes for orchestrating teams of agents. + +Teams follow the same channel pattern as individual agents, enabling: +- Composable teams (teams can consume from other teams) +- DAG-based agent relationships +- Two execution modes: Sync (one-shot) and Live (long-running with channels) + +Relation semantics: +- relations = [(ChannelA, ChannelB), ...] where A and B are Channel types +- Means: A's producer publishes to A's channel, B has a consumer on A's channel +- When B's consumer receives from A, it triggers B's producer + +Execution modes: +- SyncAgentTeam: Direct execution in topological order, no channels/consumers +- LiveAgentTeam: Full channel-based execution with consumer wiring +""" +import abc +import asyncio +import typing +from collections import defaultdict + +import octobot_commons.logging as logging + +from octobot_agents.channel import ( + AbstractAgentChannel, + AbstractAgentChannelConsumer, + AbstractAgentChannelProducer, + AbstractAIAgentChannelConsumer, + AbstractAIAgentChannelProducer, +) + +from octobot_agents.team.team_manager import ( + AbstractTeamManagerAgent, + DefaultTeamManagerAgentProducer, + ExecutionPlan, + MODIFICATION_ADDITIONAL_INSTRUCTIONS, + MODIFICATION_CUSTOM_PROMPT, + MODIFICATION_EXECUTION_HINTS, +) + + +class AbstractAgentTeamChannelConsumer(AbstractAgentChannelConsumer): + """ + Consumer for team outputs. + + Can be used to consume results from a team's final output channel. + """ + __metaclass__ = abc.ABCMeta + + +class AbstractAgentTeamChannelProducer(AbstractAgentChannelProducer, abc.ABC): + """ + Base producer for agent teams with common DAG logic. + + This class provides: + - DAG computation from relations + - Entry/terminal agent identification + - Topological ordering for execution + + Subclasses implement different execution modes: + - AbstractSyncAgentTeamChannelProducer: Direct one-shot execution + - AbstractLiveAgentTeamChannelProducer: Channel-based long-running execution + + Relation semantics: + - relations = [(A, B), ...] where A and B are Channel types + - Means: A's producer output feeds into B's producer input + """ + + # Override in subclasses with dedicated channel and consumer classes + TEAM_CHANNEL: typing.Optional[typing.Type["AbstractAgentTeamChannel"]] = None + TEAM_CONSUMER: typing.Optional[typing.Type[AbstractAgentTeamChannelConsumer]] = None + TEAM_NAME: str = "AbstractAgentTeam" + + def __init__( + self, + channel: typing.Optional["AbstractAgentTeamChannel"], + agents: typing.List[AbstractAIAgentChannelProducer], + relations: typing.List[typing.Tuple[typing.Type[AbstractAgentChannel], typing.Type[AbstractAgentChannel]]], + ai_service: typing.Any, + team_name: typing.Optional[str] = None, + team_id: typing.Optional[str] = None, + manager: typing.Optional[AbstractTeamManagerAgent] = None, + ): + """ + Initialize the agent team producer. + + Args: + channel: The team's output channel (optional). + agents: List of agent producer instances. + relations: List of (SourceAgentChannel, TargetAgentChannel) edges. + e.g., [(SignalAIAgentChannel, RiskAIAgentChannel)] means + RiskAgent receives input from SignalAgent. + ai_service: The AI service for LLM calls. + team_name: Name of the team (defaults to TEAM_NAME). + team_id: Unique identifier for this team instance. + manager: Optional team manager agent. If None, creates DefaultTeamManagerAgentProducer. + """ + super().__init__(channel) + self.agents = agents + self.relations = relations + self.ai_service = ai_service + self.team_name = team_name or self.TEAM_NAME + self.team_id = team_id or "" + self.logger = logging.get_logger(f"{self.__class__.__name__}{f'[{self.team_id}]' if self.team_id else ''}") + + # Initialize manager - use default if not provided + if manager is None: + self.manager = DefaultTeamManagerAgentProducer(channel=None) + else: + self.manager = manager + + self._producer_by_channel: typing.Dict[typing.Type[AbstractAgentChannel], AbstractAIAgentChannelProducer] = {} + self._producer_by_name: typing.Dict[str, AbstractAIAgentChannelProducer] = {} + for agent in self.agents: + if agent.AGENT_CHANNEL is not None: + self._producer_by_channel[agent.AGENT_CHANNEL] = agent + self._producer_by_name[agent.AGENT_NAME] = agent + + def _build_dag(self) -> typing.Tuple[ + typing.Dict[typing.Type[AbstractAgentChannel], typing.List[typing.Type[AbstractAgentChannel]]], + typing.Dict[typing.Type[AbstractAgentChannel], typing.List[typing.Type[AbstractAgentChannel]]] + ]: + """ + Build DAG edge mappings from relations. + + Returns: + Tuple of (incoming_edges, outgoing_edges) dicts. + - incoming_edges[B] = [A, ...] means B receives from A + - outgoing_edges[A] = [B, ...] means A sends to B + """ + incoming_edges: typing.Dict[typing.Type[AbstractAgentChannel], typing.List[typing.Type[AbstractAgentChannel]]] = defaultdict(list) + outgoing_edges: typing.Dict[typing.Type[AbstractAgentChannel], typing.List[typing.Type[AbstractAgentChannel]]] = defaultdict(list) + + for source_channel, target_channel in self.relations: + incoming_edges[target_channel].append(source_channel) + outgoing_edges[source_channel].append(target_channel) + + return incoming_edges, outgoing_edges + + def _get_entry_agents(self) -> typing.List[AbstractAIAgentChannelProducer]: + """ + Get agents with no incoming edges (entry points). + + Returns: + List of agent producers that have no dependencies. + """ + incoming_edges, _ = self._build_dag() + entry_agents = [] + + for agent in self.agents: + channel_type = agent.AGENT_CHANNEL + if channel_type is None: + continue + # Entry: no incoming edges + if channel_type not in incoming_edges or not incoming_edges[channel_type]: + entry_agents.append(agent) + + return entry_agents + + def _get_terminal_agents(self) -> typing.List[AbstractAIAgentChannelProducer]: + """ + Get agents with no outgoing edges (terminal points). + + Returns: + List of agent producers that have no dependents. + """ + _, outgoing_edges = self._build_dag() + terminal_agents = [] + + for agent in self.agents: + channel_type = agent.AGENT_CHANNEL + if channel_type is None: + continue + # Terminal: no outgoing edges + if channel_type not in outgoing_edges or not outgoing_edges[channel_type]: + terminal_agents.append(agent) + + return terminal_agents + + def _get_execution_order(self) -> typing.List[AbstractAIAgentChannelProducer]: + """ + Get topological order of agents for sequential execution. + + Uses Kahn's algorithm for topological sorting. + + Returns: + List of agent producers in execution order. + """ + incoming_edges, outgoing_edges = self._build_dag() + + # Count incoming edges for each node + in_degree: typing.Dict[typing.Type[AbstractAgentChannel], int] = defaultdict(int) + for agent in self.agents: + channel_type = agent.AGENT_CHANNEL + if channel_type is not None: + in_degree[channel_type] = len(incoming_edges.get(channel_type, [])) + + # Start with nodes that have no incoming edges + queue: typing.List[typing.Type[AbstractAgentChannel]] = [ + channel_type for channel_type, degree in in_degree.items() if degree == 0 + ] + + ordered_channels: typing.List[typing.Type[AbstractAgentChannel]] = [] + + while queue: + current = queue.pop(0) + ordered_channels.append(current) + + # Reduce in-degree for all successors + for successor in outgoing_edges.get(current, []): + in_degree[successor] -= 1 + if in_degree[successor] == 0: + queue.append(successor) + + # Convert channel types back to producers + return [self._producer_by_channel[ch] for ch in ordered_channels if ch in self._producer_by_channel] + + async def _execute_plan( + self, + execution_plan: ExecutionPlan, + initial_data: typing.Dict[str, typing.Any], + ) -> typing.Dict[str, typing.Any]: + """ + Execute an ExecutionPlan. + + Args: + execution_plan: The execution plan to execute + initial_data: Initial data to pass to entry agents + + Returns: + Dict with results from terminal agents + """ + incoming_edges, _ = self._build_dag() + terminal_agents = self._get_terminal_agents() + + # Store results by agent name + results: typing.Dict[str, typing.Dict[str, typing.Any]] = {} + completed_agents: typing.Set[str] = set() + + iteration = 0 + max_iterations = execution_plan.max_iterations or 1 + + while iteration < max_iterations: + iteration += 1 + self.logger.debug(f"Executing plan iteration {iteration}/{max_iterations}") + + # Execute each step in the plan + for step in execution_plan.steps: + if step.skip: + self.logger.debug(f"Skipping agent: {step.agent_name}") + continue + + agent = self._producer_by_name.get(step.agent_name) + if agent is None: + self.logger.warning(f"Agent {step.agent_name} not found in team") + continue + + # Wait for dependencies if specified + if step.wait_for: + for dep_name in step.wait_for: + if dep_name not in completed_agents: + self.logger.debug(f"Waiting for dependency: {dep_name}") + # In a real implementation, we might want to wait for actual completion + # For now, we assume dependencies are already completed + + # Send instructions if provided + if step.instructions: + instruction_dict: typing.Dict[str, typing.Any] = {} + for instruction in step.instructions: + if instruction.modification_type == MODIFICATION_ADDITIONAL_INSTRUCTIONS: + instruction_dict[MODIFICATION_ADDITIONAL_INSTRUCTIONS] = instruction.value + elif instruction.modification_type == MODIFICATION_CUSTOM_PROMPT: + instruction_dict[MODIFICATION_CUSTOM_PROMPT] = instruction.value + elif instruction.modification_type == MODIFICATION_EXECUTION_HINTS: + instruction_dict[MODIFICATION_EXECUTION_HINTS] = instruction.value + + if instruction_dict: + await self.manager.send_instruction_to_agent(agent, instruction_dict) + + # Gather inputs from predecessors + channel_type = agent.AGENT_CHANNEL + if channel_type is None: + continue + + predecessors = incoming_edges.get(channel_type, []) + + if not predecessors: + # Entry agent: use initial_data + agent_input = initial_data + else: + # Non-entry agent: gather predecessor outputs + agent_input = {} + for pred_channel in predecessors: + pred_agent = self._producer_by_channel.get(pred_channel) + if pred_agent and pred_agent.AGENT_NAME in results: + pred_result = results[pred_agent.AGENT_NAME] + agent_input[pred_agent.AGENT_NAME] = { + AbstractAgentChannel.AGENT_NAME_KEY: pred_agent.AGENT_NAME, + AbstractAgentChannel.AGENT_ID_KEY: "", + AbstractAgentChannel.RESULT_KEY: pred_result.get(AbstractAgentChannel.RESULT_KEY), + } + + # Execute agent + self.logger.debug(f"Executing agent: {agent.AGENT_NAME}") + try: + result = await agent.execute(agent_input, self.ai_service) + results[agent.AGENT_NAME] = { + AbstractAgentChannel.AGENT_NAME_KEY: agent.AGENT_NAME, + AbstractAgentChannel.AGENT_ID_KEY: "", + AbstractAgentChannel.RESULT_KEY: result, + } + completed_agents.add(agent.AGENT_NAME) + except Exception as e: + self.logger.error(f"Agent {agent.AGENT_NAME} execution failed: {e}") + raise + + # Check loop condition + if not execution_plan.loop: + break + + # Evaluate loop condition (simplified - in real implementation, this would be more sophisticated) + if execution_plan.loop_condition: + self.logger.debug(f"Loop condition: {execution_plan.loop_condition}") + # For now, we'll break after one iteration if loop_condition is set + # In a real implementation, this would evaluate the condition + + # Collect terminal results + terminal_results: typing.Dict[str, typing.Any] = {} + for agent in terminal_agents: + if agent.AGENT_NAME in results: + terminal_results[agent.AGENT_NAME] = results[agent.AGENT_NAME].get(AbstractAgentChannel.RESULT_KEY) + + return terminal_results + + @abc.abstractmethod + async def run(self, initial_data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """ + Execute the team pipeline. + + Args: + initial_data: Initial data to pass to entry agents. + + Returns: + Dict with results from terminal agents. + """ + raise NotImplementedError("run must be implemented by subclasses") + + async def push( + self, + result: typing.Any, + agent_name: typing.Optional[str] = None, + agent_id: typing.Optional[str] = None, + ) -> None: + """Push team result to the team's channel.""" + if self.channel is None: + return + + team_name = agent_name or self.team_name + for consumer_instance in self.channel.get_filtered_consumers( + agent_name=team_name, + agent_id=agent_id or self.team_id, + ): + await consumer_instance.queue.put({ + AbstractAgentChannel.AGENT_NAME_KEY: team_name, + AbstractAgentChannel.AGENT_ID_KEY: agent_id or self.team_id, + AbstractAgentChannel.RESULT_KEY: result, + }) + + +class AbstractSyncAgentTeamChannelProducer(AbstractAgentTeamChannelProducer): + """ + Sync (one-shot) team producer for direct sequential execution. + + Executes agents in topological order without using channels or consumers. + Each agent's execute() is called directly with outputs from predecessors. + + Use this for: + - Simple sequential pipelines + - One-shot batch processing + - Testing and debugging + """ + + async def run(self, initial_data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """ + Execute the team pipeline synchronously using the manager. + + 1. Get ExecutionPlan from manager.execute() + 2. Execute the plan + 3. Return terminal agent results + + Args: + initial_data: Initial data to pass to entry agents. + + Returns: + Dict with results from all terminal agents. + """ + # Build input_data for manager + manager_input = { + "team_producer": self, + "initial_data": initial_data, + "instructions": None, # Can be extended to accept instructions + } + + # Get execution plan from manager + execution_plan = await self.manager.execute(manager_input, self.ai_service) + + # Execute the plan + terminal_results = await self._execute_plan(execution_plan, initial_data) + + self.logger.debug(f"Sync execution completed with {len(terminal_results)} results") + + # Push team result if we have a channel + if self.channel is not None: + await self.push(terminal_results) + + return terminal_results + + +class AbstractLiveAgentTeamChannelProducer(AbstractAgentTeamChannelProducer): + """ + Live (long-running) team producer with full channel-based execution. + + Creates channels for each agent and wires consumers based on relations. + Agents communicate asynchronously through their channels. + + Use this for: + - Long-running pipelines with continuous updates + - Complex DAG workflows with parallel execution + - Reactive systems where agents respond to events + """ + + def __init__( + self, + channel: typing.Optional["AbstractAgentTeamChannel"], + agents: typing.List[AbstractAIAgentChannelProducer], + relations: typing.List[typing.Tuple[typing.Type[AbstractAgentChannel], typing.Type[AbstractAgentChannel]]], + ai_service: typing.Any, + team_name: typing.Optional[str] = None, + team_id: typing.Optional[str] = None, + manager: typing.Optional[AbstractTeamManagerAgent] = None, + ): + super().__init__(channel, agents, relations, ai_service, team_name, team_id, manager) + + # Live-specific state + self._channels: typing.Dict[typing.Type[AbstractAgentChannel], AbstractAgentChannel] = {} + self._entry_agents: typing.List[AbstractAIAgentChannelProducer] = [] + self._terminal_agents: typing.List[AbstractAIAgentChannelProducer] = [] + self._terminal_results: typing.Dict[str, typing.Any] = {} + self._completion_event: typing.Optional[asyncio.Event] = None + + async def setup(self) -> None: + """ + Create channels for all agents and wire consumers based on relations. + + This method: + 1. Creates a channel instance for each agent using agent.AGENT_CHANNEL + 2. Identifies entry agents (no incoming edges in relations) + 3. Identifies terminal agents (no outgoing edges in relations) + 4. For each relation (A, B): registers B's consumer on A's channel + """ + incoming_edges, outgoing_edges = self._build_dag() + + # Create channels and map producers + for agent in self.agents: + if agent.AGENT_CHANNEL is None: + raise ValueError(f"Agent {agent.__class__.__name__} has no AGENT_CHANNEL defined") + + channel_type = agent.AGENT_CHANNEL + # Pass team_name and team_id to channels + channel_instance = channel_type( + team_name=self.team_name, + team_id=self.team_id, + ) + self._channels[channel_type] = channel_instance + + # Set the channel on the producer + agent.channel = channel_instance + agent.ai_service = self.ai_service + + # Identify entry and terminal agents + self._entry_agents = self._get_entry_agents() + self._terminal_agents = self._get_terminal_agents() + + # Wire consumers based on relations + for source_channel_type, target_channel_type in self.relations: + source_channel = self._channels.get(source_channel_type) + target_producer = self._producer_by_channel.get(target_channel_type) + + if source_channel is None: + self.logger.warning(f"Source channel {source_channel_type.__name__} not found in team") + continue + if target_producer is None: + self.logger.warning(f"Target producer for {target_channel_type.__name__} not found in team") + continue + + # Calculate expected inputs for target + expected_inputs = len(incoming_edges[target_channel_type]) + + # Create consumer for the target that listens on source's channel + consumer_class = target_producer.AGENT_CONSUMER or AbstractAIAgentChannelConsumer + consumer_instance = consumer_class( + callback=self._create_consumer_callback(target_producer, target_channel_type), + expected_inputs=expected_inputs, + ) + consumer_instance.set_producer(target_producer) + + # Register consumer on source channel + await source_channel.new_consumer( + consumer_instance=consumer_instance, + agent_name=self._producer_by_channel[source_channel_type].AGENT_NAME, + ) + + # Wire terminal agent callbacks to collect results + for terminal_agent in self._terminal_agents: + terminal_channel = self._channels.get(terminal_agent.AGENT_CHANNEL) + if terminal_channel: + await terminal_channel.new_consumer( + callback=self._create_terminal_callback(terminal_agent), + agent_name=terminal_agent.AGENT_NAME, + ) + + self.logger.debug( + f"Team setup complete: {len(self._entry_agents)} entry agents, " + f"{len(self._terminal_agents)} terminal agents, " + f"{len(self.relations)} relations" + ) + + def _create_consumer_callback( + self, + target_producer: AbstractAIAgentChannelProducer, + target_channel_type: typing.Type[AbstractAgentChannel], + ) -> typing.Callable: + """Create a callback that aggregates inputs and triggers the producer.""" + + # Track received inputs for this target (key: agent_name) + received_inputs: typing.Dict[str, typing.Dict[str, typing.Any]] = {} + incoming_edges, _ = self._build_dag() + expected_count = len(incoming_edges.get(target_channel_type, [])) + + async def callback(data: dict) -> None: + source_name = data.get(AbstractAgentChannel.AGENT_NAME_KEY, "unknown") + source_id = data.get(AbstractAgentChannel.AGENT_ID_KEY, "") + result = data.get(AbstractAgentChannel.RESULT_KEY) + + # Store with both name and id for full context + received_inputs[source_name] = { + AbstractAgentChannel.AGENT_NAME_KEY: source_name, + AbstractAgentChannel.AGENT_ID_KEY: source_id, + AbstractAgentChannel.RESULT_KEY: result, + } + + self.logger.debug( + f"Target {target_producer.AGENT_NAME} received input from {source_name}[{source_id}] " + f"({len(received_inputs)}/{expected_count})" + ) + + # Trigger when all inputs received + if len(received_inputs) >= expected_count: + self.logger.debug(f"Triggering {target_producer.AGENT_NAME} with {len(received_inputs)} inputs") + try: + # Pass the full input data including agent_id + result = await target_producer.execute(received_inputs.copy(), self.ai_service) + await target_producer.push(result) + except Exception as e: + self.logger.error(f"Agent {target_producer.AGENT_NAME} execution failed: {e}") + raise + finally: + received_inputs.clear() + + return callback + + def _create_terminal_callback( + self, + terminal_agent: AbstractAIAgentChannelProducer, + ) -> typing.Callable: + """Create a callback that collects terminal agent results.""" + + async def callback(data: dict) -> None: + result = data.get(AbstractAgentChannel.RESULT_KEY) + self._terminal_results[terminal_agent.AGENT_NAME] = result + + self.logger.debug( + f"Terminal agent {terminal_agent.AGENT_NAME} completed " + f"({len(self._terminal_results)}/{len(self._terminal_agents)})" + ) + + # Check if all terminal agents completed + if len(self._terminal_results) >= len(self._terminal_agents): + if self._completion_event: + self._completion_event.set() + + return callback + + async def run(self, initial_data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """ + Execute the team pipeline with channels. + + 1. Setup channels and consumers (if not already done) + 2. Start entry agents with initial_data + 3. Wait for terminal agents to complete + 4. Produce team output to team's channel + + Args: + initial_data: Initial data to pass to entry agents. + + Returns: + Dict with results from all terminal agents. + """ + # Setup if not already done + if not self._channels: + await self.setup() + + # Clear previous results + self._terminal_results.clear() + self._completion_event = asyncio.Event() + + # Start entry agents + self.logger.debug(f"Starting {len(self._entry_agents)} entry agents") + + entry_tasks = [] + for entry_agent in self._entry_agents: + async def run_entry(agent: AbstractAIAgentChannelProducer) -> None: + try: + result = await agent.execute(initial_data, self.ai_service) + await agent.push(result) + except Exception as e: + self.logger.error(f"Entry agent {agent.AGENT_NAME} failed: {e}") + raise + + entry_tasks.append(asyncio.create_task(run_entry(entry_agent))) + + # Wait for all entry agents to complete + if entry_tasks: + await asyncio.gather(*entry_tasks) + + # Wait for terminal agents to complete (with timeout) + try: + await asyncio.wait_for(self._completion_event.wait(), timeout=300.0) + except asyncio.TimeoutError: + self.logger.error("Team execution timed out waiting for terminal agents") + raise + + self.logger.debug(f"Team execution completed with {len(self._terminal_results)} results") + + # Push team result if we have a channel + if self.channel is not None: + await self.push(self._terminal_results) + + return self._terminal_results + + async def stop(self) -> None: + """Stop all agents and cleanup channels.""" + for channel in self._channels.values(): + try: + await channel.stop() + except Exception as e: + self.logger.warning(f"Error stopping channel: {e}") + + self._channels.clear() + self._entry_agents.clear() + self._terminal_agents.clear() + self._terminal_results.clear() + + self.logger.debug("Team stopped") + + +class AbstractAgentTeamChannel(AbstractAgentChannel): + """ + Channel for team outputs. + + Allows teams to be composed - one team's output can feed another team. + """ + __metaclass__ = abc.ABCMeta + + PRODUCER_CLASS = AbstractAgentTeamChannelProducer + CONSUMER_CLASS = AbstractAgentTeamChannelConsumer diff --git a/packages/agents/octobot_agents/team/team_manager.py b/packages/agents/octobot_agents/team/team_manager.py new file mode 100644 index 000000000..91b82bc26 --- /dev/null +++ b/packages/agents/octobot_agents/team/team_manager.py @@ -0,0 +1,332 @@ +# This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . +""" +Team manager agent classes for orchestrating team execution. + +Managers are responsible for the execution process of teams. They can be: +- DefaultTeamManagerAgent: Simple agent that executes in topological order +- AITeamManagerAgent: AI-powered agent that uses LLM to decide execution flow +""" +import abc +import typing +from typing import Dict, List, Optional, Union + +from pydantic import BaseModel + +import octobot_commons.logging as logging + +from octobot_agents.channel import ( + AbstractAgentChannel, + AbstractAgentChannelConsumer, + AbstractAgentChannelProducer, + AbstractAIAgentChannelProducer, + AbstractAIAgentChannelConsumer, +) + + +# ============================================================================= +# Modification Constants +# ============================================================================= + +MODIFICATION_ADDITIONAL_INSTRUCTIONS = "additional_instructions" +MODIFICATION_CUSTOM_PROMPT = "custom_prompt" +MODIFICATION_EXECUTION_HINTS = "execution_hints" + + +# ============================================================================= +# Pydantic Models for Execution Plan +# ============================================================================= + +class AgentInstruction(BaseModel): + """Instruction to send to an agent via channel.modify()""" + modification_type: str # One of MODIFICATION_ADDITIONAL_INSTRUCTIONS, MODIFICATION_CUSTOM_PROMPT, etc. + value: Union[str, Dict[str, typing.Any]] # The instruction content (string for prompts, dict for hints) + + +class ExecutionStep(BaseModel): + """Single step in the execution plan""" + agent_name: str + instructions: Optional[List[AgentInstruction]] = None # Instructions to send before execution + wait_for: Optional[List[str]] = None # Agent names to wait for before executing + skip: bool = False # Skip this agent in this iteration + + +class ExecutionPlan(BaseModel): + """Complete execution plan - returned by both DefaultTeamManagerAgent and AITeamManagerAgent""" + steps: List[ExecutionStep] + loop: bool = False # Whether to loop execution + loop_condition: Optional[str] = None # Condition description for looping + max_iterations: Optional[int] = None # Maximum loop iterations + + +# ============================================================================= +# Abstract Team Manager Agent +# ============================================================================= + +class AbstractTeamManagerAgent(abc.ABC): + """ + Base interface for all team managers. + + Both managers are agents and follow the agent pattern with channels. + """ + + def __init__(self): + """Initialize the team manager agent.""" + self.logger = logging.get_logger(self.__class__.__name__) + + @abc.abstractmethod + async def execute(self, input_data: typing.Any, ai_service: typing.Any) -> ExecutionPlan: + """ + Execute the manager's logic and return an execution plan. + + Args: + input_data: Contains {"team_producer": team_producer, "initial_data": initial_data, "instructions": instructions} + ai_service: The AI service instance (for AI managers) + + Returns: + ExecutionPlan with steps for team execution + """ + raise NotImplementedError("execute must be implemented by subclasses") + + async def send_instruction_to_agent( + self, + agent: AbstractAIAgentChannelProducer, + instruction: Dict[str, typing.Any], + ) -> None: + """ + Send instruction to an agent via channel.modify(). + + Args: + agent: The agent producer to send instructions to + instruction: Dict with modification constants as keys (e.g., {MODIFICATION_ADDITIONAL_INSTRUCTIONS: "..."}) + """ + if agent.channel is None: + self.logger.warning(f"Agent {agent.AGENT_NAME} has no channel, cannot send instructions") + return + + await agent.channel.modify(**instruction) + + +# ============================================================================= +# Default Team Manager Agent (Non-AI) +# ============================================================================= + +class DefaultTeamManagerAgentChannel(AbstractAgentChannel): + """Channel for default team manager.""" + pass + + +class DefaultTeamManagerAgentConsumer(AbstractAgentChannelConsumer): + """Consumer for default team manager.""" + pass + + +class DefaultTeamManagerAgentProducer(AbstractAgentChannelProducer, AbstractTeamManagerAgent): + """ + Default team manager agent - simple agent that executes in topological order. + + Inherits from AbstractAgentChannelProducer AND AbstractTeamManagerAgent. + Has Channel, Producer, Consumer components (as all agents do). + """ + + AGENT_NAME: str = "DefaultTeamManagerAgent" + AGENT_CHANNEL: typing.Type[AbstractAgentChannel] = DefaultTeamManagerAgentChannel + AGENT_CONSUMER: typing.Type[AbstractAgentChannelConsumer] = DefaultTeamManagerAgentConsumer + + def __init__( + self, + channel: typing.Optional[DefaultTeamManagerAgentChannel] = None, + ): + AbstractTeamManagerAgent.__init__(self) + AbstractAgentChannelProducer.__init__(self, channel) + + async def execute(self, input_data: typing.Any, ai_service: typing.Any) -> ExecutionPlan: + """ + Build execution plan from topological sort. + + Args: + input_data: Contains {"team_producer": team_producer, "initial_data": initial_data, "instructions": instructions} + ai_service: Not used by default manager + + Returns: + ExecutionPlan with steps in topological order + """ + team_producer = input_data.get("team_producer") + if team_producer is None: + raise ValueError("team_producer is required in input_data") + + # Get execution order (topological sort) + execution_order = team_producer._get_execution_order() + incoming_edges, _ = team_producer._build_dag() + + # Build ExecutionPlan + steps: List[ExecutionStep] = [] + for agent in execution_order: + # Get predecessors for wait_for + channel_type = agent.AGENT_CHANNEL + if channel_type is None: + continue + + predecessors = incoming_edges.get(channel_type, []) + wait_for: Optional[List[str]] = None + if predecessors: + wait_for = [] + for pred_channel in predecessors: + pred_agent = team_producer._producer_by_channel.get(pred_channel) + if pred_agent: + wait_for.append(pred_agent.AGENT_NAME) + + step = ExecutionStep( + agent_name=agent.AGENT_NAME, + instructions=None, # No instructions by default + wait_for=wait_for, + skip=False, + ) + steps.append(step) + + return ExecutionPlan( + steps=steps, + loop=False, + loop_condition=None, + max_iterations=None, + ) + + +# ============================================================================= +# AI Team Manager Agent +# ============================================================================= + +class AITeamManagerAgentChannel(AbstractAgentChannel): + """Channel for AI team manager.""" + pass + + +class AITeamManagerAgentConsumer(AbstractAgentChannelConsumer): + """Consumer for AI team manager.""" + pass + + +class AITeamManagerAgentProducer(AbstractAIAgentChannelProducer, AbstractTeamManagerAgent): + """ + AI team manager agent - uses LLM to decide execution flow. + + Inherits from AbstractAIAgentChannelProducer AND AbstractTeamManagerAgent. + Has Channel, Producer, Consumer components (as all AI agents do). + """ + + AGENT_NAME: str = "AITeamManagerAgent" + AGENT_CHANNEL: typing.Type[AbstractAgentChannel] = AITeamManagerAgentChannel + AGENT_CONSUMER: typing.Type[AbstractAgentChannelConsumer] = AITeamManagerAgentConsumer + + def __init__( + self, + channel: typing.Optional[AITeamManagerAgentChannel] = None, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + ): + AbstractTeamManagerAgent.__init__(self) + AbstractAIAgentChannelProducer.__init__(self, channel, model=model, max_tokens=max_tokens, temperature=temperature) + + def _get_default_prompt(self) -> str: + """ + Return the default prompt for the AI team manager. + + Returns: + The default system prompt string. + """ + return """You are a team execution manager for an agent team system. +Your role is to analyze the team structure, current state, and any instructions, +then create an execution plan that determines: +1. Which agents to execute +2. In what order +3. What instructions to send to each agent +4. Whether to loop execution + +The execution plan should optimize for the team's goals while respecting dependencies.""" + + async def execute(self, input_data: typing.Any, ai_service: typing.Any) -> ExecutionPlan: + """ + Build execution plan using LLM. + + Args: + input_data: Contains {"team_producer": team_producer, "initial_data": initial_data, "instructions": instructions} + ai_service: The AI service instance for LLM calls + + Returns: + ExecutionPlan from LLM + """ + team_producer = input_data.get("team_producer") + initial_data = input_data.get("initial_data", {}) + instructions = input_data.get("instructions") + + if team_producer is None: + raise ValueError("team_producer is required in input_data") + + # Build context + agents_info = [] + for agent in team_producer.agents: + agents_info.append({ + "name": agent.AGENT_NAME, + "channel": agent.AGENT_CHANNEL.__name__ if agent.AGENT_CHANNEL else None, + }) + + relations_info = [] + for source_channel, target_channel in team_producer.relations: + relations_info.append({ + "source": source_channel.__name__, + "target": target_channel.__name__, + }) + + context = { + "team_name": team_producer.team_name, + "agents": agents_info, + "relations": relations_info, + "initial_data": initial_data, + "instructions": instructions, + } + + # Build messages for LLM + messages = [ + {"role": "system", "content": self.prompt}, + { + "role": "user", + "content": f"""Analyze the following team structure and create an execution plan: + +Team: {team_producer.team_name} +Agents: {self.format_data(agents_info)} +Relations: {self.format_data(relations_info)} +Initial Data: {self.format_data(initial_data)} +Instructions: {self.format_data(instructions) if instructions else "None"} + +Create an execution plan that determines the order and instructions for each agent.""" + }, + ] + + # Call LLM with ExecutionPlan as response schema + response_data = await self._call_llm( + messages, + ai_service, + json_output=True, + response_schema=ExecutionPlan, + ) + + # Parse into ExecutionPlan model + execution_plan = ExecutionPlan(**response_data) + + self.logger.info(f"Generated execution plan with {len(execution_plan.steps)} steps") + + return execution_plan diff --git a/pants.toml b/pants.toml new file mode 100644 index 000000000..967bc3152 --- /dev/null +++ b/pants.toml @@ -0,0 +1,22 @@ +# Pants configuration for OctoBot +[GLOBAL] +pants_version = "2.30.0" +backend_packages = [ + "pants.backend.python", +] + +[source] +# Configure source roots for packages directory +root_patterns = [ + "/packages/*", + "/", +] + +[python] +# Python interpreter compatibility +interpreter_constraints = [">=3.10"] + +[python-infer] +# Enable dependency inference +inits = true +conftests = true diff --git a/requirements.txt b/requirements.txt index a9f3993b4..4f805c7ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ # Drakkar-Software requirements -OctoBot-Commons==1.10.4 -OctoBot-Trading==2.5.0 -OctoBot-Evaluators==1.10.0 +OctoBot-Commons==1.10.6 +OctoBot-Trading==2.5.1 +OctoBot-Evaluators==1.10.1 OctoBot-Tentacles-Manager==2.10.0 -OctoBot-Services==1.7.0 +OctoBot-Services==1.7.2 OctoBot-Backtesting==1.10.0 Async-Channel==2.2.2 trading-backend==1.2.43