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