From a434ea2391b10110e92e9f08a90bef9615595c55 Mon Sep 17 00:00:00 2001 From: Herklos Date: Sat, 24 Jan 2026 00:30:45 +0100 Subject: [PATCH 1/2] [AI] Add agents Signed-off-by: Herklos --- octobot/agent/__init__.py | 57 ++++ octobot/agent/channel.py | 475 ++++++++++++++++++++++++++++++ octobot/agent/team.py | 598 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 1130 insertions(+) create mode 100644 octobot/agent/__init__.py create mode 100644 octobot/agent/channel.py create mode 100644 octobot/agent/team.py diff --git a/octobot/agent/__init__.py b/octobot/agent/__init__.py new file mode 100644 index 000000000..b7c124a96 --- /dev/null +++ b/octobot/agent/__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.agent import channel +from octobot.agent import team + +# Base agent channel classes +from octobot.agent.channel import ( + AbstractAgentChannel, + AbstractAgentChannelProducer, + AbstractAgentChannelConsumer, +) + +# AI agent channel classes (with LLM capabilities) +from octobot.agent.channel import ( + AbstractAIAgentChannelProducer, + AbstractAIAgentChannelConsumer, +) + +# Team channel classes +from octobot.agent.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/octobot/agent/channel.py b/octobot/agent/channel.py new file mode 100644 index 000000000..8716f5146 --- /dev/null +++ b/octobot/agent/channel.py @@ -0,0 +1,475 @@ +# 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: + RuntimeError: 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 + if attempt < self.MAX_RETRIES: + self.logger.warning( + f"LLM call failed on attempt {attempt}/{self.MAX_RETRIES} " + f"for agent {self.AGENT_NAME}: {str(e)}. Retrying..." + ) + else: + self.logger.error( + f"LLM call failed on final attempt {attempt}/{self.MAX_RETRIES} " + f"for agent {self.AGENT_NAME}: {str(e)}" + ) + + # All retries exhausted + raise RuntimeError( + 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/octobot/agent/team.py b/octobot/agent/team.py new file mode 100644 index 000000000..e10d99a74 --- /dev/null +++ b/octobot/agent/team.py @@ -0,0 +1,598 @@ +# 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.agent.channel import ( + AbstractAgentChannel, + AbstractAgentChannelConsumer, + AbstractAgentChannelProducer, + AbstractAIAgentChannelConsumer, + AbstractAIAgentChannelProducer, +) + + +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, + ): + """ + 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. + """ + 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 ''}") + + self._producer_by_channel: typing.Dict[typing.Type[AbstractAgentChannel], AbstractAIAgentChannelProducer] = {} + for agent in self.agents: + if agent.AGENT_CHANNEL is not None: + self._producer_by_channel[agent.AGENT_CHANNEL] = 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] + + @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 in topological order. + + 1. Compute topological order of agents + 2. For each agent in order: + - Gather outputs from predecessor agents + - Call execute() directly + - Store result for successor agents + 3. Return terminal agent results + + Args: + initial_data: Initial data to pass to entry agents. + + Returns: + Dict with results from all terminal agents. + """ + execution_order = self._get_execution_order() + incoming_edges, _ = self._build_dag() + terminal_agents = self._get_terminal_agents() + + # Store results by channel type + results: typing.Dict[typing.Type[AbstractAgentChannel], typing.Dict[str, typing.Any]] = {} + + self.logger.info(f"Starting sync execution with {len(execution_order)} agents") + + for agent in execution_order: + channel_type = agent.AGENT_CHANNEL + if channel_type is None: + continue + + # Gather inputs from predecessors + 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_channel in results: + pred_result = results[pred_channel] + 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.info(f"Executing agent: {agent.AGENT_NAME}") + try: + result = await agent.execute(agent_input, self.ai_service) + results[channel_type] = { + AbstractAgentChannel.AGENT_NAME_KEY: agent.AGENT_NAME, + AbstractAgentChannel.AGENT_ID_KEY: "", + AbstractAgentChannel.RESULT_KEY: result, + } + except Exception as e: + self.logger.error(f"Agent {agent.AGENT_NAME} execution failed: {e}") + raise + + # Collect terminal results + terminal_results: typing.Dict[str, typing.Any] = {} + for agent in terminal_agents: + channel_type = agent.AGENT_CHANNEL + if channel_type and channel_type in results: + terminal_results[agent.AGENT_NAME] = results[channel_type].get(AbstractAgentChannel.RESULT_KEY) + + self.logger.info(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, + ): + super().__init__(channel, agents, relations, ai_service, team_name, team_id) + + # 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.info( + 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.info(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.info(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.info(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.info("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 From bd80761e75f3fffb692490c72e9c06b0c49ac90c Mon Sep 17 00:00:00 2001 From: Herklos Date: Sat, 24 Jan 2026 12:58:51 +0100 Subject: [PATCH 2/2] [Agent] Add manager --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 4 + full_requirements.txt | 2 +- octobot/agent/channel.py | 475 ------------ octobot/agent/team.py | 598 --------------- .../backtesting/independent_backtesting.py | 4 +- packages/agents/BUILD | 8 + packages/agents/README.md | 1 + packages/agents/octobot_agents/__init__.py | 90 +++ .../agents/octobot_agents}/agent/__init__.py | 53 +- .../octobot_agents/agent/channels/__init__.py | 36 + .../octobot_agents/agent/channels/agent.py | 187 +++++ .../octobot_agents/agent/channels/ai_agent.py | 472 ++++++++++++ .../octobot_agents/agent/memory/__init__.py | 30 + .../agent/memory/channels/__init__.py | 35 + .../agent/memory/channels/memory_agent.py | 184 +++++ .../octobot_agents/agent/memory/util.py | 116 +++ packages/agents/octobot_agents/constants.py | 70 ++ packages/agents/octobot_agents/enums.py | 33 + packages/agents/octobot_agents/errors.py | 49 ++ packages/agents/octobot_agents/models.py | 498 +++++++++++++ .../agents/octobot_agents/storage/__init__.py | 32 + .../octobot_agents/storage/memory/__init__.py | 41 ++ .../storage/memory/abstract_memory_storage.py | 180 +++++ .../octobot_agents/storage/memory/factory.py | 65 ++ .../storage/memory/json_memory_storage.py | 618 ++++++++++++++++ .../octobot_agents/storage/memory/tools.py | 151 ++++ .../agents/octobot_agents/team/__init__.py | 55 ++ .../octobot_agents/team/channels/__init__.py | 34 + .../team/channels/agents_team.py | 686 ++++++++++++++++++ .../team/channels/ai_agents_team.py | 406 +++++++++++ .../octobot_agents/team/critic/__init__.py | 36 + .../team/critic/channels/__init__.py | 35 + .../team/critic/channels/critic_agent.py | 132 ++++ .../octobot_agents/team/judge/__init__.py | 35 + .../team/judge/channels/__init__.py | 35 + .../team/judge/channels/judge_agent.py | 135 ++++ .../octobot_agents/team/manager/__init__.py | 47 ++ .../team/manager/channels/__init__.py | 47 ++ .../team/manager/channels/manager_agent.py | 461 ++++++++++++ packages/agents/tests/__init__.py | 15 + packages/agents/tests/test_agent.py | 74 ++ pants.toml | 22 + requirements.txt | 2 +- setup.py | 3 + 45 files changed, 5188 insertions(+), 1104 deletions(-) create mode 100644 .DS_Store delete mode 100644 octobot/agent/channel.py delete mode 100644 octobot/agent/team.py create mode 100644 packages/agents/BUILD create mode 100644 packages/agents/README.md create mode 100644 packages/agents/octobot_agents/__init__.py rename {octobot => packages/agents/octobot_agents}/agent/__init__.py (61%) create mode 100644 packages/agents/octobot_agents/agent/channels/__init__.py create mode 100644 packages/agents/octobot_agents/agent/channels/agent.py create mode 100644 packages/agents/octobot_agents/agent/channels/ai_agent.py create mode 100644 packages/agents/octobot_agents/agent/memory/__init__.py create mode 100644 packages/agents/octobot_agents/agent/memory/channels/__init__.py create mode 100644 packages/agents/octobot_agents/agent/memory/channels/memory_agent.py create mode 100644 packages/agents/octobot_agents/agent/memory/util.py create mode 100644 packages/agents/octobot_agents/constants.py create mode 100644 packages/agents/octobot_agents/enums.py create mode 100644 packages/agents/octobot_agents/errors.py create mode 100644 packages/agents/octobot_agents/models.py create mode 100644 packages/agents/octobot_agents/storage/__init__.py create mode 100644 packages/agents/octobot_agents/storage/memory/__init__.py create mode 100644 packages/agents/octobot_agents/storage/memory/abstract_memory_storage.py create mode 100644 packages/agents/octobot_agents/storage/memory/factory.py create mode 100644 packages/agents/octobot_agents/storage/memory/json_memory_storage.py create mode 100644 packages/agents/octobot_agents/storage/memory/tools.py create mode 100644 packages/agents/octobot_agents/team/__init__.py create mode 100644 packages/agents/octobot_agents/team/channels/__init__.py create mode 100644 packages/agents/octobot_agents/team/channels/agents_team.py create mode 100644 packages/agents/octobot_agents/team/channels/ai_agents_team.py create mode 100644 packages/agents/octobot_agents/team/critic/__init__.py create mode 100644 packages/agents/octobot_agents/team/critic/channels/__init__.py create mode 100644 packages/agents/octobot_agents/team/critic/channels/critic_agent.py create mode 100644 packages/agents/octobot_agents/team/judge/__init__.py create mode 100644 packages/agents/octobot_agents/team/judge/channels/__init__.py create mode 100644 packages/agents/octobot_agents/team/judge/channels/judge_agent.py create mode 100644 packages/agents/octobot_agents/team/manager/__init__.py create mode 100644 packages/agents/octobot_agents/team/manager/channels/__init__.py create mode 100644 packages/agents/octobot_agents/team/manager/channels/manager_agent.py create mode 100644 packages/agents/tests/__init__.py create mode 100644 packages/agents/tests/test_agent.py create mode 100644 pants.toml diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f30af1f317076beb18b2c1ceef87c4f69d819e92 GIT binary patch literal 6148 zcmeHK%}T>S5T0$TO(;SS3OxqA7Oa02@e*o%0V8@)sfjH$G-gYannNk%tS{t~_&m<+ zZj@4c@FG%XVD_7xoyoG_hMipi5S?j%AD|8Z7Aj%M#^wj1andCz84saQ-$-Eq38WB0 zycErj|HuICT@3>8;Sol-bKm#ILLbS?QC8?K$Ubb(=eD)8yLWck8%58teASc+{QHz_87$xpjTNhV@g{L3 z;~_>A*+mv3Gr$Zm1MAL!IrpsEx-XR1#|$t7zh;2W2M3kVHJEEuM+Y`^eWdXUAqm>_ zmLPNtx(0KNID#T{Dxyvm=7}M6I{FU0olX8ewsS(q1!P_v`oQQ;t5jodN= z%)lZ8W!T#~7+ON*mg>!3cMl2BZ(@hb%lRf;i| eN^t{K3Hlu}5M6`0M)aWYML^NO4Kwhk415CC-cGLo literal 0 HcmV?d00001 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 e60a79151..958aa5efc 100644 --- a/full_requirements.txt +++ b/full_requirements.txt @@ -1,6 +1,6 @@ # Drakkar-Software full requirements OctoBot-Commons[full]==1.10.6 -OctoBot-Trading[full]==2.5.4 +OctoBot-Trading[full]==2.5.5 OctoBot-Evaluators[full]==1.10.1 OctoBot-Tentacles-Manager[full]==2.10.0 OctoBot-Services[full]==1.7.2 diff --git a/octobot/agent/channel.py b/octobot/agent/channel.py deleted file mode 100644 index 8716f5146..000000000 --- a/octobot/agent/channel.py +++ /dev/null @@ -1,475 +0,0 @@ -# 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: - RuntimeError: 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 - if attempt < self.MAX_RETRIES: - self.logger.warning( - f"LLM call failed on attempt {attempt}/{self.MAX_RETRIES} " - f"for agent {self.AGENT_NAME}: {str(e)}. Retrying..." - ) - else: - self.logger.error( - f"LLM call failed on final attempt {attempt}/{self.MAX_RETRIES} " - f"for agent {self.AGENT_NAME}: {str(e)}" - ) - - # All retries exhausted - raise RuntimeError( - 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/octobot/agent/team.py b/octobot/agent/team.py deleted file mode 100644 index e10d99a74..000000000 --- a/octobot/agent/team.py +++ /dev/null @@ -1,598 +0,0 @@ -# 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.agent.channel import ( - AbstractAgentChannel, - AbstractAgentChannelConsumer, - AbstractAgentChannelProducer, - AbstractAIAgentChannelConsumer, - AbstractAIAgentChannelProducer, -) - - -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, - ): - """ - 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. - """ - 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 ''}") - - self._producer_by_channel: typing.Dict[typing.Type[AbstractAgentChannel], AbstractAIAgentChannelProducer] = {} - for agent in self.agents: - if agent.AGENT_CHANNEL is not None: - self._producer_by_channel[agent.AGENT_CHANNEL] = 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] - - @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 in topological order. - - 1. Compute topological order of agents - 2. For each agent in order: - - Gather outputs from predecessor agents - - Call execute() directly - - Store result for successor agents - 3. Return terminal agent results - - Args: - initial_data: Initial data to pass to entry agents. - - Returns: - Dict with results from all terminal agents. - """ - execution_order = self._get_execution_order() - incoming_edges, _ = self._build_dag() - terminal_agents = self._get_terminal_agents() - - # Store results by channel type - results: typing.Dict[typing.Type[AbstractAgentChannel], typing.Dict[str, typing.Any]] = {} - - self.logger.info(f"Starting sync execution with {len(execution_order)} agents") - - for agent in execution_order: - channel_type = agent.AGENT_CHANNEL - if channel_type is None: - continue - - # Gather inputs from predecessors - 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_channel in results: - pred_result = results[pred_channel] - 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.info(f"Executing agent: {agent.AGENT_NAME}") - try: - result = await agent.execute(agent_input, self.ai_service) - results[channel_type] = { - AbstractAgentChannel.AGENT_NAME_KEY: agent.AGENT_NAME, - AbstractAgentChannel.AGENT_ID_KEY: "", - AbstractAgentChannel.RESULT_KEY: result, - } - except Exception as e: - self.logger.error(f"Agent {agent.AGENT_NAME} execution failed: {e}") - raise - - # Collect terminal results - terminal_results: typing.Dict[str, typing.Any] = {} - for agent in terminal_agents: - channel_type = agent.AGENT_CHANNEL - if channel_type and channel_type in results: - terminal_results[agent.AGENT_NAME] = results[channel_type].get(AbstractAgentChannel.RESULT_KEY) - - self.logger.info(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, - ): - super().__init__(channel, agents, relations, ai_service, team_name, team_id) - - # 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.info( - 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.info(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.info(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.info(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.info("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/octobot/backtesting/independent_backtesting.py b/octobot/backtesting/independent_backtesting.py index bcb36b31c..a16857f72 100644 --- a/octobot/backtesting/independent_backtesting.py +++ b/octobot/backtesting/independent_backtesting.py @@ -416,6 +416,8 @@ def _find_reference_market_and_update_contract_type(self): forced_contract_type = self.octobot_origin_config.get(common_constants.CONFIG_CONTRACT_TYPE, common_constants.USE_CURRENT_PROFILE) for symbols in self.symbols_to_create_exchange_classes.values(): + if not symbols: + continue symbol = symbols[0] if next(iter(self.octobot_backtesting.exchange_type_by_exchange.values())) \ == common_constants.CONFIG_EXCHANGE_FUTURE: @@ -451,7 +453,7 @@ def _find_reference_market_and_update_contract_type(self): if ref_market_candidate != quote and \ ref_market_candidates[ref_market_candidate] < ref_market_candidates[quote]: ref_market_candidate = quote - return ref_market_candidate + return ref_market_candidate or common_constants.DEFAULT_REFERENCE_MARKET def _add_config_default_backtesting_values(self): if backtesting_constants.CONFIG_BACKTESTING not in self.backtesting_config: 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..2733aa34a --- /dev/null +++ b/packages/agents/octobot_agents/__init__.py @@ -0,0 +1,90 @@ +# 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 agent +from octobot_agents.agent import ( + AbstractAgentChannel, + AbstractAgentChannelProducer, + AbstractAgentChannelConsumer, + AbstractAIAgentChannel, + AbstractAIAgentChannelProducer, + AbstractAIAgentChannelConsumer, + AbstractMemoryAgent, + export_memories, + import_memories, +) + +from octobot_agents import storage +from octobot_agents.storage import ( + AbstractMemoryStorage, + JSONMemoryStorage, + create_memory_storage, + get_memory_tools, + execute_memory_tool, +) + +from octobot_agents import team +from octobot_agents.team import ( + AbstractAgentsTeamChannel, + AbstractAgentsTeamChannelProducer, + AbstractAgentsTeamChannelConsumer, + AbstractSyncAgentsTeamChannelProducer, + AbstractLiveAgentsTeamChannelProducer, + AbstractCriticAgent, + AbstractJudgeAgent, +) + +from octobot_agents import errors +from octobot_agents.errors import ( + AgentError, + TeamConfigurationError, + MissingManagerError, + MissingRequiredInputError, + AgentConfigurationError, + StorageError, + UnsupportedStorageTypeError, +) + +__all__ = [ + "AbstractAgentChannel", + "AbstractAgentChannelProducer", + "AbstractAgentChannelConsumer", + "AbstractAIAgentChannel", + "AbstractAIAgentChannelProducer", + "AbstractAIAgentChannelConsumer", + "AbstractAgentsTeamChannel", + "AbstractAgentsTeamChannelProducer", + "AbstractAgentsTeamChannelConsumer", + "AbstractSyncAgentsTeamChannelProducer", + "AbstractLiveAgentsTeamChannelProducer", + "AbstractMemoryStorage", + "JSONMemoryStorage", + "AbstractMemoryAgent", + "create_memory_storage", + "export_memories", + "import_memories", + "get_memory_tools", + "execute_memory_tool", + "AbstractCriticAgent", + "AbstractJudgeAgent", + "AgentError", + "TeamConfigurationError", + "MissingManagerError", + "MissingRequiredInputError", + "AgentConfigurationError", + "StorageError", + "UnsupportedStorageTypeError", +] diff --git a/octobot/agent/__init__.py b/packages/agents/octobot_agents/agent/__init__.py similarity index 61% rename from octobot/agent/__init__.py rename to packages/agents/octobot_agents/agent/__init__.py index b7c124a96..a0888b15f 100644 --- a/octobot/agent/__init__.py +++ b/packages/agents/octobot_agents/agent/__init__.py @@ -14,44 +14,41 @@ # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . -from octobot.agent import channel -from octobot.agent import team - -# Base agent channel classes -from octobot.agent.channel import ( +from octobot_agents.agent import channels +from octobot_agents.agent.channels import ( AbstractAgentChannel, - AbstractAgentChannelProducer, AbstractAgentChannelConsumer, -) - -# AI agent channel classes (with LLM capabilities) -from octobot.agent.channel import ( - AbstractAIAgentChannelProducer, + AbstractAgentChannelProducer, + AbstractAIAgentChannel, AbstractAIAgentChannelConsumer, + AbstractAIAgentChannelProducer, ) -# Team channel classes -from octobot.agent.team import ( - AbstractAgentTeamChannel, - AbstractAgentTeamChannelProducer, - AbstractAgentTeamChannelConsumer, - AbstractSyncAgentTeamChannelProducer, - AbstractLiveAgentTeamChannelProducer, +from octobot_agents import storage +from octobot_agents.storage import ( + AbstractMemoryStorage, + JSONMemoryStorage, + create_memory_storage, ) +from octobot_agents.agent import memory +from octobot_agents.agent.memory import ( + export_memories, + import_memories, + AbstractMemoryAgent, +) __all__ = [ - # Base classes "AbstractAgentChannel", - "AbstractAgentChannelProducer", "AbstractAgentChannelConsumer", - # AI agent classes - "AbstractAIAgentChannelProducer", + "AbstractAgentChannelProducer", + "AbstractAIAgentChannel", "AbstractAIAgentChannelConsumer", - # Team classes - "AbstractAgentTeamChannel", - "AbstractAgentTeamChannelProducer", - "AbstractAgentTeamChannelConsumer", - "AbstractSyncAgentTeamChannelProducer", - "AbstractLiveAgentTeamChannelProducer", + "AbstractAIAgentChannelProducer", + "AbstractMemoryStorage", + "JSONMemoryStorage", + "export_memories", + "import_memories", + "AbstractMemoryAgent", + "create_memory_storage", ] diff --git a/packages/agents/octobot_agents/agent/channels/__init__.py b/packages/agents/octobot_agents/agent/channels/__init__.py new file mode 100644 index 000000000..52a9261bc --- /dev/null +++ b/packages/agents/octobot_agents/agent/channels/__init__.py @@ -0,0 +1,36 @@ +# 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.agent.channels.agent import ( + AbstractAgentChannel, + AbstractAgentChannelConsumer, + AbstractAgentChannelProducer, +) + +from octobot_agents.agent.channels.ai_agent import ( + AbstractAIAgentChannel, + AbstractAIAgentChannelConsumer, + AbstractAIAgentChannelProducer, +) + +__all__ = [ + "AbstractAgentChannel", + "AbstractAgentChannelConsumer", + "AbstractAgentChannelProducer", + "AbstractAIAgentChannel", + "AbstractAIAgentChannelConsumer", + "AbstractAIAgentChannelProducer", +] diff --git a/packages/agents/octobot_agents/agent/channels/agent.py b/packages/agents/octobot_agents/agent/channels/agent.py new file mode 100644 index 000000000..614445f87 --- /dev/null +++ b/packages/agents/octobot_agents/agent/channels/agent.py @@ -0,0 +1,187 @@ +# 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 . +import abc +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 + +from octobot_agents.constants import ( + AGENT_NAME_KEY, + AGENT_ID_KEY, + AGENT_DEFAULT_VERSION +) + + +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 + + VERSION = AGENT_DEFAULT_VERSION + + 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({ + AGENT_NAME_KEY: agent_name, + 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, + { + AGENT_NAME_KEY: agent_name, + 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}" + ) diff --git a/packages/agents/octobot_agents/agent/channels/ai_agent.py b/packages/agents/octobot_agents/agent/channels/ai_agent.py new file mode 100644 index 000000000..44921206b --- /dev/null +++ b/packages/agents/octobot_agents/agent/channels/ai_agent.py @@ -0,0 +1,472 @@ +# 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 . +import abc +import contextlib +import json +import typing + +import octobot_commons.logging as logging + +from octobot_agents.agent.channels.agent import ( + AbstractAgentChannel, + AbstractAgentChannelConsumer, + AbstractAgentChannelProducer, +) +from octobot_agents.constants import ( + AGENT_DEFAULT_MAX_TOKENS, + AGENT_DEFAULT_MAX_RETRIES, + AGENT_DEFAULT_TEMPERATURE, + MEMORY_AGENT_ID_KEY, +) +from octobot_agents.storage import ( + AbstractMemoryStorage, + create_memory_storage, + get_memory_tools, + execute_memory_tool, +) +from octobot_agents.enums import MemoryStorageType +import octobot_services.services.abstract_ai_service as abstract_ai_service +from octobot_services.enums import ModelPolicy +import octobot_services.errors as services_errors + +class AbstractAIAgentChannel(AbstractAgentChannel): + """ + Channel for AI agents. + + Inherits from AbstractAgentChannel with no additional functionality. + """ + __metaclass__ = abc.ABCMeta + + +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] = {} + + 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 and optional memory management. + + Follows the same pattern as AbstractServiceFeed inheriting from + AbstractServiceFeedChannelProducer. + + Provides common functionality for LLM calling, prompt management, + and data formatting. Retry logic is handled by the service layer. + Memory functionality is optional and can be enabled via ENABLE_MEMORY class variable + or enable_memory constructor parameter. + Subclasses should implement _get_default_prompt() and execute() methods. + """ + + AGENT_VERSION: str = "1.0.0" + DEFAULT_MODEL: typing.Optional[str] = None + DEFAULT_MAX_TOKENS: int = AGENT_DEFAULT_MAX_TOKENS + DEFAULT_TEMPERATURE: float = AGENT_DEFAULT_TEMPERATURE + MAX_RETRIES: int = AGENT_DEFAULT_MAX_RETRIES + # Model policy for multi-model config: fast (analysts, debators) or reasoning (judge, final step). None = use self.model. + MODEL_POLICY: typing.Optional[ModelPolicy] = None + + # Memory configuration + ENABLE_MEMORY: bool = False + MEMORY_SEARCH_LIMIT: int = 5 + MEMORY_STORAGE_ENABLED: bool = True + MEMORY_AGENT_ID_KEY: str = MEMORY_AGENT_ID_KEY + + 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, + enable_memory: typing.Optional[bool] = 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. + enable_memory: Override class-level ENABLE_MEMORY setting. + """ + super().__init__(channel) + self.name = self.__class__.__name__ + 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: abstract_ai_service.AbstractAIService = None + self.logger = logging.get_logger(f"{self.__class__.__name__}") + + # Initialize memory storage if memory is enabled + memory_enabled = enable_memory if enable_memory is not None else self.ENABLE_MEMORY + self.memory_manager: AbstractMemoryStorage = create_memory_storage( + MemoryStorageType.JSON, + agent_name=self.__class__.__name__, + agent_version=self.AGENT_VERSION, + enabled=memory_enabled, + search_limit=self.MEMORY_SEARCH_LIMIT, + storage_enabled=self.MEMORY_STORAGE_ENABLED, + agent_id_key=self.MEMORY_AGENT_ID_KEY, + ) + + def has_memory_enabled(self) -> bool: + """ + Check if memory is enabled for this agent. + + Returns: + True if memory is enabled, False otherwise. + """ + return self.memory_manager.is_enabled() + + @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: abstract_ai_service.AbstractAIService) -> 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 name). + agent_id: Agent id for filtering. + """ + if self.channel is None: + return + await self.perform( + result, + agent_name=agent_name or self.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, + }) + + @contextlib.contextmanager + def _memory_tool_executor(self): + """ + Context manager that provides a memory tool executor callback. + + Yields: + A callable function that executes memory tools with the signature: + (tool_name: str, arguments: dict) -> Any + """ + def executor(tool_name: str, arguments: dict) -> typing.Any: + return execute_memory_tool(self.memory_manager, tool_name, arguments) + + yield executor + + async def _call_llm( + self, + messages: list, + llm_service: abstract_ai_service.AbstractAIService, + json_output: bool = True, + response_schema: typing.Optional[typing.Any] = None, + input_data: typing.Optional[typing.Any] = None, + memory_query: typing.Optional[str] = None, + tools: typing.Optional[list] = None, + return_tool_calls: bool = False, + ) -> typing.Any: + """ + Common LLM calling method with error handling and optional memory. + + Automatically registers memory tools when memory is enabled. Memory retrieval is done + via LLM tools (get_memory_summaries, get_memory_by_id). + Custom tools can be provided and will be merged with memory tools if both are present. + Retry logic is handled by the service layer via the retry_llm_completion decorator. + + 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. + input_data: Optional input data for memory retrieval (kept for backward compatibility). + memory_query: Optional custom query for memory search (not used with tools). + tools: Optional list of custom tools to provide to the LLM. + + Returns: + Parsed JSON dict or raw string response. + """ + # Register memory tools if memory is enabled, and merge with custom tools + all_tools = [] + if self.memory_manager.is_enabled(): + all_tools.extend(get_memory_tools(self.memory_manager, llm_service)) + if tools: + all_tools.extend(tools) + + # 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() + + # Resolve model from policy if set (use class attribute MODEL_POLICY) + effective_model = self.model + if self.MODEL_POLICY is not None: + policy_model = llm_service.get_model_for_policy(self.MODEL_POLICY.value) + if policy_model: + effective_model = policy_model + + # Call LLM with automatic tool calling orchestration if tools are available + # Retry logic is handled by the service layer decorator + if all_tools: + # Use context manager to get executor and keep it open for the entire call + try: + with self._memory_tool_executor() as executor: + # Call LLM with automatic tool calling orchestration + return await llm_service.get_completion_with_tools( + messages=messages, + tool_executor=executor if not return_tool_calls else None, + model=effective_model, + max_tokens=self.max_tokens, + temperature=self.temperature, + json_output=json_output, + response_schema=effective_schema, + tools=all_tools, + return_tool_calls=return_tool_calls, + ) + except services_errors.InvalidRequestError as e: + # Check if error is due to tool support + error_message = str(e).lower() + if "does not support tools" in error_message or "does not support" in error_message and "tool" in error_message: + # Model doesn't support tools - fall back to regular completion + self.logger.warning( + f"Model {self.model} does not support tools. " + f"Falling back to regular completion without memory tools. " + f"Error: {e}" + ) + # Fall through to regular get_completion below + else: + # Different error - re-raise it + raise + except Exception as e: + # Check if it's a tool support error from the underlying API + error_message = str(e).lower() + if "does not support tools" in error_message or "does not support" in error_message and "tool" in error_message: + # Model doesn't support tools - fall back to regular completion + self.logger.warning( + f"Model {self.model} does not support tools. " + f"Falling back to regular completion without memory tools. " + f"Error: {e}" + ) + # Fall through to regular get_completion below + else: + # Different error - re-raise it + raise + + # No tools or fallback from tool error - use regular get_completion + response = await llm_service.get_completion( + messages=messages, + model=effective_model, + max_tokens=self.max_tokens, + temperature=self.temperature, + json_output=json_output, + response_schema=effective_schema, + tools=None, + ) + return llm_service.parse_completion_response( + response, + json_output=json_output + ) + + 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) + + async def _get_relevant_memories( + self, + query: str, + input_data: typing.Any, + limit: typing.Optional[int] = None, + ) -> typing.List[dict]: + """ + Retrieve relevant memories for the current context. + + Note: With tool-based approach, memories are retrieved via LLM tools. + This method is kept for backward compatibility but may not be used. + + Args: + query: Search query for finding relevant memories. + input_data: Current input data (may contain agent_id). + limit: Maximum number of memories to retrieve (defaults to MEMORY_SEARCH_LIMIT). + + Returns: + List of memory dictionaries with 'memory' and 'metadata' keys. + """ + return await self.memory_manager.search_memories(query, input_data, limit=limit) + + def _format_memories_for_prompt(self, memories: typing.List[dict]) -> str: + """ + Format memories for inclusion in prompts. + + Delegates to memory manager. + + Args: + memories: List of memory dictionaries. + + Returns: + Formatted string with memories, or empty string if none. + """ + return self.memory_manager.format_memories_for_prompt(memories) + + async def _store_execution_memory( + self, + input_data: typing.Any, + output: typing.Any, + user_message: typing.Optional[str] = None, + assistant_message: typing.Optional[str] = None, + metadata: typing.Optional[dict] = None, + ) -> None: + """ + Store memory from agent execution. + + Note: Memory storage is now handled by MemoryAgent, not automatically after LLM calls. + This method is kept for backward compatibility but should not be called automatically. + + Args: + input_data: The input data that was processed. + output: The agent's output/result. + user_message: Optional user message (auto-built if not provided). + assistant_message: Optional assistant message (auto-built if not provided). + metadata: Optional metadata to attach. + """ + # Memory storage is now handled by MemoryAgent + # This method is kept for manual memory storage if needed + await self.memory_manager.store_execution_memory( + input_data, output, user_message, assistant_message, metadata + ) diff --git a/packages/agents/octobot_agents/agent/memory/__init__.py b/packages/agents/octobot_agents/agent/memory/__init__.py new file mode 100644 index 000000000..5aea225f9 --- /dev/null +++ b/packages/agents/octobot_agents/agent/memory/__init__.py @@ -0,0 +1,30 @@ +# 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.agent.memory import channels +from octobot_agents.agent.memory.channels import ( + AbstractMemoryAgent, +) + +from octobot_agents.agent.memory import util +from octobot_agents.agent.memory.util import export_memories, import_memories + + +__all__ = [ + "export_memories", + "import_memories", + "AbstractMemoryAgent", +] diff --git a/packages/agents/octobot_agents/agent/memory/channels/__init__.py b/packages/agents/octobot_agents/agent/memory/channels/__init__.py new file mode 100644 index 000000000..c2b4957bf --- /dev/null +++ b/packages/agents/octobot_agents/agent/memory/channels/__init__.py @@ -0,0 +1,35 @@ +# 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.agent.memory.channels.memory_agent import ( + AbstractMemoryAgent, + MemoryAgentChannel, + MemoryAgentConsumer, + MemoryAgentProducer, + AIMemoryAgentChannel, + AIMemoryAgentConsumer, + AIMemoryAgentProducer, +) + +__all__ = [ + "AbstractMemoryAgent", + "MemoryAgentChannel", + "MemoryAgentConsumer", + "MemoryAgentProducer", + "AIMemoryAgentChannel", + "AIMemoryAgentConsumer", + "AIMemoryAgentProducer", +] diff --git a/packages/agents/octobot_agents/agent/memory/channels/memory_agent.py b/packages/agents/octobot_agents/agent/memory/channels/memory_agent.py new file mode 100644 index 000000000..effdc0ca9 --- /dev/null +++ b/packages/agents/octobot_agents/agent/memory/channels/memory_agent.py @@ -0,0 +1,184 @@ +# 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 . +import abc +import typing +from typing import TYPE_CHECKING, Optional + +import octobot_commons.logging as logging + +from octobot_agents.agent.channels.agent import ( + AbstractAgentChannel, + AbstractAgentChannelConsumer, + AbstractAgentChannelProducer, +) +from octobot_agents.agent.channels.ai_agent import ( + AbstractAIAgentChannel, + AbstractAIAgentChannelConsumer, + AbstractAIAgentChannelProducer, +) +import octobot_agents.models as models +import octobot_services.services.abstract_ai_service as abstract_ai_service + + +class AbstractMemoryAgent(abc.ABC): + """ + Base interface for all memory agents. + + Memory agents are responsible for managing agent memories based on critic analysis. + """ + + def __init__(self, self_improving: bool = True): + """Initialize the memory agent.""" + self.self_improving = self_improving + self.logger = None # Will be set by subclasses + + @abc.abstractmethod + async def execute( + self, + input_data: typing.Union[models.MemoryInput, typing.Dict[str, typing.Any]], + ai_service: abstract_ai_service.AbstractAIService + ) -> models.MemoryOperation: + """ + Execute memory operations based on critic analysis. + + Args: + input_data: Contains {"critic_analysis": CriticAnalysis, "agent_outputs": Dict, "execution_metadata": dict} + ai_service: The AI service instance (for AI memory agents) + + Returns: + MemoryOperation with list of operations performed + """ + raise NotImplementedError("execute must be implemented by subclasses") + + @staticmethod + def _get_agent_from_team( + team_producer: typing.Optional[typing.Any], + agent_name: str + ) -> Optional["AbstractAIAgentChannelProducer"]: + """ + Get agent instance from team producer (manager or regular agent). + + Args: + team_producer: The team producer instance. + agent_name: Name of the agent to retrieve. + + Returns: + The agent instance if found, None otherwise. + """ + if not team_producer: + return None + manager = team_producer.get_manager() + if manager and manager.name == agent_name: + return manager + return team_producer.get_agent_by_name(agent_name) + + @staticmethod + def _collect_all_agent_names( + agent_outputs: typing.Dict[str, typing.Any], + team_producer: typing.Optional[typing.Any] + ) -> typing.Set[str]: + """ + Collect all agent names from outputs and team producer. + + Args: + agent_outputs: Dict of agent outputs. + team_producer: The team producer instance. + + Returns: + Set of all agent names. + """ + all_agent_names = set(agent_outputs.keys()) + if team_producer: + manager = team_producer.get_manager() + if manager: + try: + all_agent_names.add(manager.name) + except AttributeError: + pass + return all_agent_names + + +# ----------------------------------------------------------------------------- +# Base (non-AI) channel classes +# ----------------------------------------------------------------------------- + + +class MemoryAgentChannel(AbstractAgentChannel): + """Base channel for memory agents.""" + __slots__ = () + OUTPUT_SCHEMA = models.MemoryOperation + + +class MemoryAgentConsumer(AbstractAgentChannelConsumer): + """Base consumer for memory agent channels.""" + __slots__ = () + + +class MemoryAgentProducer(AbstractAgentChannelProducer, AbstractMemoryAgent): + """Base producer for memory agents. Subclasses implement execute().""" + __slots__ = () + + AGENT_CHANNEL = MemoryAgentChannel + AGENT_CONSUMER = MemoryAgentConsumer + + def __init__(self, channel: Optional[MemoryAgentChannel] = None, self_improving: bool = True): + AbstractMemoryAgent.__init__(self, self_improving=self_improving) + AbstractAgentChannelProducer.__init__(self, channel) + self.name = self.__class__.__name__ + self.logger = logging.get_logger(self.__class__.__name__) + + +# ----------------------------------------------------------------------------- +# AI channel classes (inherit from base AND AI abstracts) +# ----------------------------------------------------------------------------- + + +class AIMemoryAgentChannel(MemoryAgentChannel, AbstractAIAgentChannel): + """AI channel for memory agents.""" + __slots__ = () + + +class AIMemoryAgentConsumer(MemoryAgentConsumer, AbstractAIAgentChannelConsumer): + """AI consumer for memory agent channels.""" + __slots__ = () + + +class AIMemoryAgentProducer(MemoryAgentProducer, AbstractAIAgentChannelProducer): + """AI producer for memory agents. Tentacles extend this and implement execute() with LLM.""" + __slots__ = () + + AGENT_CHANNEL = AIMemoryAgentChannel + AGENT_CONSUMER = AIMemoryAgentConsumer + + def __init__( + self, + channel: Optional[AIMemoryAgentChannel] = None, + model: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + self_improving: bool = True, + **kwargs, + ): + AbstractMemoryAgent.__init__(self, self_improving=self_improving) + AbstractAIAgentChannelProducer.__init__( + self, channel, model=model, max_tokens=max_tokens, temperature=temperature, **kwargs + ) + self.name = self.__class__.__name__ + self.logger = logging.get_logger(self.__class__.__name__) + + +if TYPE_CHECKING: + from octobot_agents.agent.channels.ai_agent import AbstractAIAgentChannelProducer diff --git a/packages/agents/octobot_agents/agent/memory/util.py b/packages/agents/octobot_agents/agent/memory/util.py new file mode 100644 index 000000000..d2eb2982b --- /dev/null +++ b/packages/agents/octobot_agents/agent/memory/util.py @@ -0,0 +1,116 @@ +# 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 . +""" +Utility functions for memory export and import. +""" +import json +import os +import typing + +import octobot_commons.logging as logging + +from octobot_agents.storage import JSONMemoryStorage + +logger = logging.get_logger("MemoryUtil") + + +def export_memories(memory_manager: JSONMemoryStorage, file_path: str) -> None: + """ + Export all memories to a JSON file. + + Args: + memory_manager: The memory manager instance. + file_path: Path to export file. + """ + if not memory_manager or not memory_manager.is_enabled(): + logger.warning("Memory manager is not enabled, cannot export") + return + + try: + # Ensure directory exists + directory = os.path.dirname(file_path) + if directory: + os.makedirs(directory, exist_ok=True) + + data = { + "agent_version": memory_manager.agent_version, + "memories": memory_manager.get_all_memories(), + } + + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + logger.info(f"Exported {len(data['memories'])} memories to {file_path}") + except Exception as e: + logger.error(f"Error exporting memories: {e}") + raise + + +def import_memories( + memory_manager: JSONMemoryStorage, + file_path: str, + merge: bool = False, +) -> None: + """ + Import memories from a JSON file. + + Args: + memory_manager: The memory manager instance. + file_path: Path to import file. + merge: If True, merge with existing memories (check for duplicates by id). + If False, replace all existing memories. + """ + if not memory_manager or not memory_manager.is_enabled(): + logger.warning("Memory manager is not enabled, cannot import") + return + + if not os.path.exists(file_path): + logger.error(f"Import file not found: {file_path}") + return + + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + imported_memories = data.get("memories", []) + imported_version = data.get("agent_version") + + # Validate version + if imported_version and imported_version != memory_manager.agent_version: + logger.warning( + f"Version mismatch: imported={imported_version}, " + f"current={memory_manager.agent_version}" + ) + + if merge: + # Merge: check for duplicates by id + existing_ids = {m.get("id") for m in memory_manager.get_all_memories()} + new_memories = [m for m in imported_memories if m.get("id") not in existing_ids] + memory_manager._memories.extend(new_memories) + logger.info(f"Merged {len(new_memories)} new memories (skipped {len(imported_memories) - len(new_memories)} duplicates)") + else: + # Replace + memory_manager._memories = imported_memories + logger.info(f"Replaced all memories with {len(imported_memories)} imported memories") + + # Prune if needed + if len(memory_manager._memories) > memory_manager.max_memories: + memory_manager._prune_memories() + + memory_manager._save_memories() + except Exception as e: + logger.error(f"Error importing memories: {e}") + raise diff --git a/packages/agents/octobot_agents/constants.py b/packages/agents/octobot_agents/constants.py new file mode 100644 index 000000000..f3d4f3a00 --- /dev/null +++ b/packages/agents/octobot_agents/constants.py @@ -0,0 +1,70 @@ +# 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 . + +AGENT_NAME_KEY = "agent_name" +AGENT_ID_KEY = "agent_id" +TEAM_NAME_KEY = "team_name" +TEAM_ID_KEY = "team_id" +RESULT_KEY = "result" + +# Agent defaults +AGENT_DEFAULT_VERSION = "1.0.0" +AGENT_DEFAULT_MAX_TOKENS: int = 10000 +AGENT_DEFAULT_TEMPERATURE: float = 0.3 +AGENT_DEFAULT_MAX_RETRIES: int = 3 + +# Memory keys +MEMORY_USER_ID_KEY = "user_id" +MEMORY_AGENT_ID_KEY = "agent_id" + +# Memory operations +MEMORY_OPERATION_GENERATE = "generate" +MEMORY_OPERATION_MERGE = "merge" +MEMORY_OPERATION_UPDATE = "update" +MEMORY_OPERATION_REMOVE = "remove" +MEMORY_OPERATION_GROUP = "group" + +# Memory defaults +DEFAULT_CATEGORY = "general" +DEFAULT_IMPORTANCE_SCORE = 0.5 +DEFAULT_CONFIDENCE_SCORE = 0.5 +DEFAULT_MAX_MEMORIES = 100 + +# Memory length limits +MEMORY_TITLE_MAX_LENGTH = 100 +MEMORY_CONTEXT_MAX_LENGTH = 200 +MEMORY_CONTENT_MAX_LENGTH = 500 + +# Storage constants +MEMORY_FOLDER_NAME = "agents" +MEMORY_FILE_EXTENSION = ".json" + +# Team modification constants +MODIFICATION_ADDITIONAL_INSTRUCTIONS = "additional_instructions" +MODIFICATION_CUSTOM_PROMPT = "custom_prompt" +MODIFICATION_EXECUTION_HINTS = "execution_hints" + +# Critic analysis types +ANALYSIS_TYPE_ISSUES = "issues" +ANALYSIS_TYPE_IMPROVEMENTS = "improvements" +ANALYSIS_TYPE_ERRORS = "errors" +ANALYSIS_TYPE_INCONSISTENCIES = "inconsistencies" +ANALYSIS_TYPE_OPTIMIZATIONS = "optimizations" + +# Manager tool names +TOOL_RUN_AGENT = "run_agent" +TOOL_RUN_DEBATE = "run_debate" +TOOL_FINISH = "finish" diff --git a/packages/agents/octobot_agents/enums.py b/packages/agents/octobot_agents/enums.py new file mode 100644 index 000000000..ac9544d4d --- /dev/null +++ b/packages/agents/octobot_agents/enums.py @@ -0,0 +1,33 @@ +# 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 . +import enum + + +class MemoryStorageType(enum.Enum): + """Enum for memory storage types.""" + JSON = "json" + + +class StepType(enum.Enum): + """Enum for execution step type (agent vs debate).""" + AGENT = "agent" + DEBATE = "debate" + + +class JudgeDecisionType(enum.Enum): + """Enum for judge decision in a debate step (continue or exit).""" + CONTINUE = "continue" + EXIT = "exit" diff --git a/packages/agents/octobot_agents/errors.py b/packages/agents/octobot_agents/errors.py new file mode 100644 index 000000000..7b86b432e --- /dev/null +++ b/packages/agents/octobot_agents/errors.py @@ -0,0 +1,49 @@ +# 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 . + +class AgentError(Exception): + """Base exception for all octobot_agents errors.""" + pass + + +class TeamConfigurationError(AgentError): + """Raised when a team is misconfigured.""" + pass + + +class MissingManagerError(TeamConfigurationError): + """Raised when a team requires a manager but none is provided.""" + pass + + +class MissingRequiredInputError(AgentError): + """Raised when required input data is missing.""" + pass + + +class AgentConfigurationError(AgentError): + """Raised when an agent is misconfigured.""" + pass + + +class StorageError(AgentError): + """Raised when there's an error with storage operations.""" + pass + + +class UnsupportedStorageTypeError(StorageError): + """Raised when an unsupported storage type is requested.""" + pass diff --git a/packages/agents/octobot_agents/models.py b/packages/agents/octobot_agents/models.py new file mode 100644 index 000000000..5e3028873 --- /dev/null +++ b/packages/agents/octobot_agents/models.py @@ -0,0 +1,498 @@ +# 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 . +import typing +from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict, Union + +import pydantic +from pydantic import BaseModel, ConfigDict, Field + +from octobot_agents import constants +from octobot_agents.errors import AgentError + +if TYPE_CHECKING: + from octobot_agents.team.channels.agents_team import AbstractAgentsTeamChannelProducer + + +# ============================================================================ +# Base Model for JSON Schema Strict Mode Control +# ============================================================================ + +class AgentBaseModel(BaseModel): + """ + Base Pydantic model for OctoBot agents with JSON schema strict mode control. + + Models can override __strict_json_schema__ to control strict mode: + - False (default): Disable strict mode (for models with Union types) + - True: Enable strict mode (for models without Union types) + """ + __strict_json_schema__: bool = False + + +# ============================================================================ +# Execution Plan Models +# ============================================================================ + +class AgentInstruction(AgentBaseModel): + """Instruction to send to an agent via channel.modify()""" + model_config = ConfigDict(extra="forbid") + + 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 DebatePhaseConfig(AgentBaseModel): + """Configuration for a debate phase: debators take turns, judge decides continue or exit.""" + model_config = ConfigDict(extra="forbid") + + debator_agent_names: List[str] # Agent names that debate (e.g. Bull, Bear) + judge_agent_name: str # Agent name of the judge that decides continue/exit + max_rounds: int = 3 # Maximum debate rounds before forcing exit + + +class JudgeDecision(AgentBaseModel): + """Result of a judge agent execute(): continue or exit the debate, with reasoning and optional summary.""" + model_config = ConfigDict(extra="forbid") + + decision: str # JudgeDecisionType.CONTINUE.value or JudgeDecisionType.EXIT.value + reasoning: str + summary: Optional[str] = None # When decision is exit, concise synthesis; when continue, None + + +class ExecutionStep(AgentBaseModel): + """Single step in the execution plan""" + model_config = ConfigDict(extra="forbid") + + # Agent name may be omitted for debate steps (we'll fill a default). + agent_name: Optional[str] = None + # Allow instructions as either structured AgentInstruction objects or simple strings + instructions: Optional[Union[List[AgentInstruction], List[str]]] = 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 + # Debate step: when step_type is StepType.DEBATE.value, use debate_config instead of single agent + step_type: Optional[str] = None # StepType.AGENT.value (default) or StepType.DEBATE.value + debate_config: Optional[DebatePhaseConfig] = None # Required when step_type == StepType.DEBATE.value + + @pydantic.model_validator(mode="after") + def validate_and_normalize(self) -> "ExecutionStep": + """Normalize and validate step after construction. + + - For debate steps, set default agent_name if missing. + - Convert plain string instructions into AgentInstruction objects. + - Enforce agent_name requirement for non-debate steps. + """ + # Fill default agent_name for debate steps + if self.step_type == "debate" and not self.agent_name: + if self.debate_config and getattr(self.debate_config, "judge_agent_name", None): + self.agent_name = f"debate_{self.debate_config.judge_agent_name}" + else: + self.agent_name = "debate_phase" + + # Require agent_name for agent steps + if self.step_type in (None, "agent") and not self.agent_name: + raise ValueError("agent_name is required for agent steps") + + # Normalize instructions: convert plain strings to AgentInstruction + if self.instructions: + normalized: List[AgentInstruction] = [] + for instr in self.instructions: + if isinstance(instr, str): + normalized.append( + AgentInstruction( + modification_type=constants.MODIFICATION_ADDITIONAL_INSTRUCTIONS, + value=instr, + ) + ) + else: + normalized.append(instr) + self.instructions = normalized + + return self + + +class ExecutionPlan(AgentBaseModel): + """Complete execution plan - returned by plan-driven AI managers like AIPlanTeamManagerAgent""" + model_config = ConfigDict(extra="forbid") + + 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 + + @classmethod + def model_validate_or_self(cls, data: Any) -> "ExecutionPlan": + """ + Validate dict to model, or return model if already validated. + + Args: + data: Either a dict to validate or an ExecutionPlan instance. + + Returns: + ExecutionPlan model instance. + """ + if isinstance(data, cls): + return data + return cls.model_validate(data) + + def to_dict(self) -> dict: + """ + Convert to dict. + + Returns: + Dict representation of the execution plan. + """ + try: + return self.model_dump() + except AttributeError: + # Fallback for Pydantic v1 + return self.dict() + + +# ============================================================================ +# Tools-driven Manager Models +# ============================================================================ + +class ManagerToolCall(AgentBaseModel): + """Tool call from LLM in tools-driven manager.""" + model_config = ConfigDict(extra="forbid") + + tool_name: str # Name of the tool to call (e.g., "run_agent", "run_debate", "finish") + arguments: Dict[str, Any] # Arguments for the tool call + + +class RunAgentArgs(AgentBaseModel): + """Arguments for run_agent tool.""" + model_config = ConfigDict(extra="forbid") + + # Allow agent_name to be optional here; manager may fill defaults or validate later. + agent_name: Optional[str] = None # Name of the agent to run + instructions: Optional[Union[List[AgentInstruction], List[str]]] = None # Instructions to send before execution + + +class RunDebateArgs(AgentBaseModel): + """Arguments for run_debate tool.""" + model_config = ConfigDict(extra="forbid") + + debator_agent_names: List[str] # Agent names that debate + judge_agent_name: str # Agent name of the judge + max_rounds: int = 3 # Maximum debate rounds + + +class ManagerState(AgentBaseModel): + """State maintained during tools-driven manager execution.""" + model_config = ConfigDict(extra="forbid") + + completed_agents: List[str] # Names of agents that have been executed + results: Dict[str, Any] # Results from completed agents + initial_data: Dict[str, Any] # Original input data + tool_call_history: List[ManagerToolCall] # History of tool calls made + + +class ManagerResult(AgentBaseModel): + """Result returned by tools-driven manager after execution.""" + model_config = ConfigDict(extra="forbid") + + completed_agents: List[str] # Names of agents that were executed + results: Dict[str, Any] # Results from completed agents (agent_name -> result) + tool_calls_used: int # Number of tool calls made during execution + + +# ============================================================================ +# Critic Models +# ============================================================================ + +class AgentImprovement(AgentBaseModel): + """Improvements needed for a specific agent.""" + __strict_json_schema__ = True + + agent_name: str # Name of the agent + improvements: List[str] # Specific improvements for this agent + issues: List[str] # Agent-specific issues + errors: List[str] # Agent-specific errors + reasoning: str # Why this agent needs improvement + + @classmethod + def model_validate_or_self(cls, data: Any) -> "AgentImprovement": + """ + Validate dict to model, or return model if already validated. + + Args: + data: Either a dict to validate or an AgentImprovement instance. + + Returns: + AgentImprovement model instance. + """ + if isinstance(data, cls): + return data + return cls.model_validate(data) + + +class CriticAnalysis(AgentBaseModel): + """Analysis result from CriticAgent.""" + __strict_json_schema__ = True + + issues: List[str] # General problems found (team-level) + errors: List[str] # General errors encountered (team-level) + inconsistencies: List[str] # Inconsistencies detected (team-level) + optimizations: List[str] # General optimization opportunities (team-level) + summary: str # Overall analysis summary + agent_improvements: Dict[str, AgentImprovement] # Agent-specific improvements + # Key: agent_name, Value: AgentImprovement + # Only includes agents that need improvements + # If agent not in dict, no improvements needed for that agent + + @classmethod + def model_validate_or_self(cls, data: Any) -> "CriticAnalysis": + """ + Validate dict to model, or return model if already validated. + + Args: + data: Either a dict to validate or a CriticAnalysis instance. + + Returns: + CriticAnalysis model instance. + """ + if isinstance(data, cls): + return data + return cls.model_validate(data) + + def get_agent_improvements(self) -> Dict[str, AgentImprovement]: + """ + Get agent improvements. + + Returns: + Dict mapping agent names to AgentImprovement objects. + """ + return self.agent_improvements + + def get_summary(self) -> str: + """ + Get summary. + + Returns: + Summary string. + """ + return self.summary + + def get_issues(self) -> List[str]: + """ + Get issues. + + Returns: + List of issue strings. + """ + return self.issues + + +# ============================================================================ +# Memory Models +# ============================================================================ + +class MemoryOperation(AgentBaseModel): + """Result of a memory operation.""" + success: bool + operations: List[str] # ["generated", "merged", "updated", "removed", "grouped"] + memory_ids: List[str] # UUIDs of affected memories (across all agents) + agent_updates: Dict[str, List[str]] # Map of agent_name -> list of memory_ids updated for that agent + agents_processed: List[str] # List of agent names that were processed + agents_skipped: List[str] # List of agent names that were skipped (no improvements needed) + message: str # Description of what happened + + +class MemoryStorageModel(AgentBaseModel): + """ + Pydantic model for memory storage with enforced structure and length limits. + + Ensures memories contain concise, precise instructions/actions/advice. + """ + title: str = pydantic.Field( + ..., + min_length=1, + max_length=constants.MEMORY_TITLE_MAX_LENGTH, + description=f"Short, clear title summarizing the memory (max {constants.MEMORY_TITLE_MAX_LENGTH} chars)" + ) + context: str = pydantic.Field( + ..., + min_length=1, + max_length=constants.MEMORY_CONTEXT_MAX_LENGTH, + description=f"Context explaining what problem this addresses (max {constants.MEMORY_CONTEXT_MAX_LENGTH} chars)" + ) + content: str = pydantic.Field( + ..., + min_length=1, + max_length=constants.MEMORY_CONTENT_MAX_LENGTH, + description=f"Concise, precise instructions/actions/advice (max {constants.MEMORY_CONTENT_MAX_LENGTH} chars). Should be summarized if longer." + ) + category: str = pydantic.Field( + default=constants.DEFAULT_CATEGORY, + description="Memory category" + ) + tags: typing.List[str] = pydantic.Field( + default_factory=list, + description="Tags for categorization" + ) + importance_score: float = pydantic.Field( + default=constants.DEFAULT_IMPORTANCE_SCORE, + ge=0.0, + le=1.0, + description="Importance score (0.0-1.0)" + ) + confidence_score: float = pydantic.Field( + default=constants.DEFAULT_CONFIDENCE_SCORE, + ge=0.0, + le=1.0, + description="Confidence score (0.0-1.0)" + ) + + @pydantic.field_validator('title', 'context', 'content') + @classmethod + def validate_not_empty(cls, v: str) -> str: + """Ensure fields are not just whitespace.""" + if not v or not v.strip(): + raise AgentError("Field cannot be empty or whitespace only") + return v.strip() + + @pydantic.field_validator('content') + @classmethod + def validate_content_format(cls, v: str) -> str: + """Ensure content is concise and actionable.""" + # Remove excessive whitespace + v = ' '.join(v.split()) + return v + + +class MemoryInstruction(AgentBaseModel): + """Instruction structure for a single memory.""" + title: str = pydantic.Field( + ..., + min_length=1, + max_length=constants.MEMORY_TITLE_MAX_LENGTH, + description=f"Short, clear title (max {constants.MEMORY_TITLE_MAX_LENGTH} chars)" + ) + structured_actions: typing.List[str] = pydantic.Field( + default_factory=list, + description="Short, direct command-like actions (imperative format)" + ) + guidance: typing.Optional[str] = pydantic.Field( + default=None, + max_length=100, + description="Optional very short guidance (max 100 chars, only if needed)" + ) + context: str = pydantic.Field( + ..., + min_length=1, + max_length=constants.MEMORY_CONTEXT_MAX_LENGTH, + description=f"Short context about what problem this addresses (max {constants.MEMORY_CONTEXT_MAX_LENGTH} chars)" + ) + + def build_content(self) -> str: + """ + Build content string as simple command list - no headers, just direct commands. + + Ensures content does not exceed MEMORY_CONTENT_MAX_LENGTH. + Format: Simple list of commands, one per line, no numbering or headers. + """ + content_parts = [] + + # Format as simple command list - no headers, remove numbering + for action in self.structured_actions: + # Remove numbering if present (e.g., "1. ", "2. "), make imperative + action_clean = action.lstrip("0123456789. ").strip() + if action_clean: + content_parts.append(action_clean) + + # Only add guidance if very short (one sentence max) + if self.guidance and len(self.guidance) < 100: + guidance_clean = self.guidance.strip() + if guidance_clean: + content_parts.append(guidance_clean) + + content = "\n".join(content_parts) if content_parts else "Follow instructions" + + # Truncate if exceeds limit (shouldn't happen if LLM follows instructions, but safety check) + if len(content) > constants.MEMORY_CONTENT_MAX_LENGTH: + truncated = content[:constants.MEMORY_CONTENT_MAX_LENGTH] + # Try to truncate at line boundary (prefer) or sentence boundary + last_newline = truncated.rfind('\n') + last_period = truncated.rfind('.') + last_break = max(last_newline, last_period) + if last_break > constants.MEMORY_CONTENT_MAX_LENGTH * 0.7: + content = truncated[:last_break + 1].strip() + else: + content = truncated.strip() + + return content + + @classmethod + def model_validate_or_self(cls, data: typing.Any) -> "MemoryInstruction": + """Validate dict to model, or return model if already validated.""" + if isinstance(data, cls): + return data + return cls.model_validate(data) + + +class AgentMemoryInstruction(AgentBaseModel): + """LLM response structure for agent memory instructions.""" + __strict_json_schema__ = True + + agent_name: str + instructions: MemoryInstruction + + @classmethod + def model_validate_or_self(cls, data: typing.Any) -> "AgentMemoryInstruction": + """Validate dict to model, or return model if already validated.""" + if isinstance(data, cls): + return data + return cls.model_validate(data) + + +class AgentMemoryInstructionsList(AgentBaseModel): + """Wrapper for list of agent memory instructions.""" + __strict_json_schema__ = True + + instructions: typing.List[AgentMemoryInstruction] + + + +class ManagerInput(TypedDict, total=False): + """Input data structure for manager agent execute() method.""" + team_producer: "AbstractAgentsTeamChannelProducer" + initial_data: Dict[str, Any] + instructions: Optional[str] + + +class CriticInput(TypedDict, total=False): + """Input data structure for critic agent execute() method.""" + team_producer: "AbstractAgentsTeamChannelProducer" + execution_plan: "ExecutionPlan" + execution_results: Dict[str, Any] + agent_outputs: Dict[str, Any] + execution_metadata: Dict[str, Any] + + +class JudgeInput(TypedDict, total=False): + """Input data structure for judge agent execute() method (debate step).""" + debate_history: List[Dict[str, Any]] # List of {agent_name, message, round} + debator_agent_names: List[str] + current_round: int + max_rounds: int + _initial_state: Dict[str, Any] # Optional context from team initial_data + + +class MemoryInput(TypedDict, total=False): + """Input data structure for memory agent execute() method.""" + critic_analysis: "CriticAnalysis" + agent_outputs: Dict[str, Any] + execution_metadata: Dict[str, Any] diff --git a/packages/agents/octobot_agents/storage/__init__.py b/packages/agents/octobot_agents/storage/__init__.py new file mode 100644 index 000000000..482add09c --- /dev/null +++ b/packages/agents/octobot_agents/storage/__init__.py @@ -0,0 +1,32 @@ +# 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.storage import memory +from octobot_agents.storage.memory import ( + AbstractMemoryStorage, + JSONMemoryStorage, + create_memory_storage, + get_memory_tools, + execute_memory_tool, +) + +__all__ = [ + "AbstractMemoryStorage", + "JSONMemoryStorage", + "create_memory_storage", + "get_memory_tools", + "execute_memory_tool", +] diff --git a/packages/agents/octobot_agents/storage/memory/__init__.py b/packages/agents/octobot_agents/storage/memory/__init__.py new file mode 100644 index 000000000..e4c8bc149 --- /dev/null +++ b/packages/agents/octobot_agents/storage/memory/__init__.py @@ -0,0 +1,41 @@ +# 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.storage.memory import abstract_memory_storage +from octobot_agents.storage.memory.abstract_memory_storage import ( + AbstractMemoryStorage +) +from octobot_agents.storage.memory import json_memory_storage +from octobot_agents.storage.memory.json_memory_storage import ( + JSONMemoryStorage +) +from octobot_agents.storage.memory import factory +from octobot_agents.storage.memory.factory import ( + create_memory_storage, +) +from octobot_agents.storage.memory import tools +from octobot_agents.storage.memory.tools import ( + get_memory_tools, + execute_memory_tool, +) + +__all__ = [ + "AbstractMemoryStorage", + "JSONMemoryStorage", + "create_memory_storage", + "get_memory_tools", + "execute_memory_tool", +] diff --git a/packages/agents/octobot_agents/storage/memory/abstract_memory_storage.py b/packages/agents/octobot_agents/storage/memory/abstract_memory_storage.py new file mode 100644 index 000000000..63b326cf1 --- /dev/null +++ b/packages/agents/octobot_agents/storage/memory/abstract_memory_storage.py @@ -0,0 +1,180 @@ +# 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 . +import abc +import typing + + +class AbstractMemoryStorage(abc.ABC): + """ + Abstract base class for memory storage. + + Defines the interface that all memory storage implementations must follow. + Memory storage is responsible for storing, retrieving, and managing + agent memories. + """ + + @abc.abstractmethod + def is_enabled(self) -> bool: + """ + Check if memory is enabled and available. + + Returns: + True if memory is enabled, False otherwise. + """ + raise NotImplementedError("is_enabled must be implemented by subclasses") + + @abc.abstractmethod + def extract_agent_id(self, input_data: typing.Any) -> str: + """ + Extract agent_id from input_data. + + Args: + input_data: Input data that may contain agent_id. + + Returns: + The agent_id string, or empty string if not found. + """ + raise NotImplementedError("extract_agent_id must be implemented by subclasses") + + @abc.abstractmethod + async def search_memories( + self, + query: str, + input_data: typing.Any, + limit: typing.Optional[int] = None, + ) -> typing.List[dict]: + """ + Search for relevant memories. + + Args: + query: Search query. + input_data: Input data containing agent_id or other context. + limit: Maximum memories to retrieve (defaults to search_limit). + + Returns: + List of memory dictionaries with 'memory' and 'metadata' keys. + """ + raise NotImplementedError("search_memories must be implemented by subclasses") + + @abc.abstractmethod + async def store_memory( + self, + messages: typing.List[dict], + input_data: typing.Any, + output: typing.Any = None, + metadata: typing.Optional[dict] = None, + ) -> None: + """ + Store memories from agent execution. + + Args: + messages: Conversation messages (user + assistant). + input_data: Input data for context. + output: Optional agent output. + metadata: Optional metadata to attach. + """ + raise NotImplementedError("store_memory must be implemented by subclasses") + + @abc.abstractmethod + def format_memories_for_prompt(self, memories: typing.List[dict]) -> str: + """ + Format memories for inclusion in prompts. + + Args: + memories: List of memory dictionaries. + + Returns: + Formatted string with memories, or empty string if none. + """ + raise NotImplementedError("format_memories_for_prompt must be implemented by subclasses") + + @abc.abstractmethod + async def store_execution_memory( + self, + input_data: typing.Any, + output: typing.Any, + user_message: typing.Optional[str] = None, + assistant_message: typing.Optional[str] = None, + metadata: typing.Optional[dict] = None, + ) -> None: + """ + Convenience method to store memory from agent execution. + + Automatically builds messages from input_data and output if not provided. + + Args: + input_data: The input data that was processed. + output: The agent's output/result. + user_message: Optional user message (auto-built if not provided). + assistant_message: Optional assistant message (auto-built if not provided). + metadata: Optional metadata to attach. + """ + raise NotImplementedError("store_execution_memory must be implemented by subclasses") + + @abc.abstractmethod + def get_all_memories(self) -> typing.List[dict]: + """ + Get all memories (for summaries). + + Returns: + List of all memory dictionaries. + """ + raise NotImplementedError("get_all_memories must be implemented by subclasses") + + @abc.abstractmethod + def get_memory_by_id(self, memory_id: str) -> typing.Optional[dict]: + """ + Get a memory by its ID. + + Args: + memory_id: The ID of the memory to retrieve. + + Returns: + The memory dictionary if found, None otherwise. + """ + raise NotImplementedError("get_memory_by_id must be implemented by subclasses") + + @abc.abstractmethod + def increment_memory_use(self, memory_id: str) -> None: + """ + Increment use_count for a memory. + + Args: + memory_id: The ID of the memory to update. + """ + raise NotImplementedError("increment_memory_use must be implemented by subclasses") + + @property + @abc.abstractmethod + def agent_version(self) -> str: + """ + Get the agent version. + + Returns: + The agent version string. + """ + raise NotImplementedError("agent_version property must be implemented by subclasses") + + @property + @abc.abstractmethod + def max_memories(self) -> int: + """ + Get the maximum number of memories. + + Returns: + The maximum number of memories that can be stored. + """ + raise NotImplementedError("max_memories property must be implemented by subclasses") diff --git a/packages/agents/octobot_agents/storage/memory/factory.py b/packages/agents/octobot_agents/storage/memory/factory.py new file mode 100644 index 000000000..aaba9b493 --- /dev/null +++ b/packages/agents/octobot_agents/storage/memory/factory.py @@ -0,0 +1,65 @@ +# 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 . +import typing + +from octobot_agents.storage.memory.abstract_memory_storage import AbstractMemoryStorage +from octobot_agents.enums import MemoryStorageType +from octobot_agents.storage.memory.json_memory_storage import JSONMemoryStorage +from octobot_agents.constants import DEFAULT_MAX_MEMORIES +from octobot_agents.errors import UnsupportedStorageTypeError + + +def create_memory_storage( + storage_type: MemoryStorageType, + agent_name: str, + agent_version: str, + enabled: bool = True, + search_limit: int = 5, + storage_enabled: bool = True, + agent_id_key: str = "agent_id", + max_memories: int = DEFAULT_MAX_MEMORIES, +) -> AbstractMemoryStorage: + """ + Factory function to create a memory storage instance based on storage type. + + Args: + storage_type: The type of storage to create (MemoryStorageType enum). + agent_name: Name of the agent using this memory storage. + agent_version: Version of the agent. + enabled: Whether memory is enabled. + search_limit: Maximum number of memories to retrieve. + storage_enabled: Whether to store new memories. + agent_id_key: Key in input_data for agent_id. + max_memories: Maximum number of memories to store. + + Returns: + An instance of AbstractMemoryStorage corresponding to the storage_type. + + Raises: + ValueError: If storage_type is not supported. + """ + if storage_type == MemoryStorageType.JSON: + return JSONMemoryStorage( + agent_name=agent_name, + agent_version=agent_version, + enabled=enabled, + search_limit=search_limit, + storage_enabled=storage_enabled, + agent_id_key=agent_id_key, + max_memories=max_memories, + ) + else: + raise UnsupportedStorageTypeError(f"Unsupported memory storage type: {storage_type}") diff --git a/packages/agents/octobot_agents/storage/memory/json_memory_storage.py b/packages/agents/octobot_agents/storage/memory/json_memory_storage.py new file mode 100644 index 000000000..079a267b6 --- /dev/null +++ b/packages/agents/octobot_agents/storage/memory/json_memory_storage.py @@ -0,0 +1,618 @@ +# 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 . +import json +import os +import sys +import typing +import uuid + +import pydantic +import octobot_commons.constants as commons_constants +import octobot_commons.logging as logging + +from octobot_agents.storage.memory.abstract_memory_storage import AbstractMemoryStorage +from octobot_agents.constants import ( + MEMORY_FOLDER_NAME, + MEMORY_FILE_EXTENSION, + DEFAULT_CATEGORY, + DEFAULT_IMPORTANCE_SCORE, + DEFAULT_CONFIDENCE_SCORE, + DEFAULT_MAX_MEMORIES, + MEMORY_TITLE_MAX_LENGTH, + MEMORY_CONTEXT_MAX_LENGTH, + MEMORY_CONTENT_MAX_LENGTH, +) +import octobot_agents.models as models + +# Platform-specific file locking +try: + if sys.platform != 'win32': + import fcntl + HAS_FCNTL = True + else: + import msvcrt + HAS_FCNTL = False + HAS_FILE_LOCKING = True +except ImportError: + HAS_FILE_LOCKING = False + + +class JSONMemoryStorage(AbstractMemoryStorage): + """ + Memory storage for AI agents using JSON file-based storage. + + Each agent has its own JSON file at `user/data/agents/memories/.json`. + Memory is stored with structured fields: id, title, context, content, category, tags, + importance_score, confidence_score, and metadata (with use_count). + """ + + def __init__( + self, + agent_name: str, + agent_version: str, + enabled: bool = True, + search_limit: int = 5, + storage_enabled: bool = True, + agent_id_key: str = "agent_id", + max_memories: int = DEFAULT_MAX_MEMORIES, + ): + """ + Initialize the memory storage. + + Args: + agent_name: Name of the agent using this memory storage. + agent_version: Version of the agent. + enabled: Whether memory is enabled. + search_limit: Maximum number of memories to retrieve. + storage_enabled: Whether to store new memories. + agent_id_key: Key in input_data for agent_id. + max_memories: Maximum number of memories to store (default: 100). + """ + self.agent_name = agent_name + self._agent_version = agent_version + self.enabled = enabled + self.search_limit = search_limit + self.storage_enabled = storage_enabled + self.agent_id_key = agent_id_key + self._max_memories = max_memories + self.logger = logging.get_logger(f"{self.__class__.__name__}[{agent_name}]") + + self._memories: typing.List[dict] = [] + self._memory_file_path: typing.Optional[str] = None + + if self.enabled: + self._memory_file_path = self._get_memory_file_path() + self._ensure_directory_exists() + self._load_memories() + self.logger.debug(f"Memory storage initialized for {agent_name} with {len(self._memories)} memories") + + def _get_memory_file_path(self) -> str: + """Build path to memory JSON file.""" + memory_dir = os.path.join( + commons_constants.USER_FOLDER, + commons_constants.DATA_FOLDER, + MEMORY_FOLDER_NAME, + "memories" + ) + # Sanitize agent_name for filename + safe_agent_name = self.agent_name.replace("/", "_").replace("\\", "_") + return os.path.join(memory_dir, f"{safe_agent_name}{MEMORY_FILE_EXTENSION}") + + def _ensure_directory_exists(self) -> None: + """Create memory directory if it doesn't exist.""" + if self._memory_file_path: + directory = os.path.dirname(self._memory_file_path) + os.makedirs(directory, exist_ok=True) + + def _load_memories(self) -> None: + """Load memories from JSON file.""" + if not self._memory_file_path or not os.path.exists(self._memory_file_path): + self._memories = [] + return + + try: + with open(self._memory_file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + # Validate agent_version + stored_version = data.get("agent_version") + if stored_version and stored_version != self.agent_version: + self.logger.warning( + f"Memory file version mismatch for {self.agent_name}: " + f"stored={stored_version}, current={self.agent_version}" + ) + + self._memories = data.get("memories", []) + self.logger.debug(f"Loaded {len(self._memories)} memories from {self._memory_file_path}") + except (json.JSONDecodeError, IOError) as e: + self.logger.warning(f"Error loading memories from {self._memory_file_path}: {e}") + self._memories = [] + + def _save_memories(self) -> None: + """Save memories to JSON file with file locking.""" + if not self._memory_file_path: + return + + try: + # Use atomic write: write to temp file, then rename + temp_path = f"{self._memory_file_path}.tmp" + + with open(temp_path, 'w', encoding='utf-8') as f: + # Acquire exclusive lock if available + if HAS_FILE_LOCKING: + try: + if HAS_FCNTL: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + else: + # Windows + file_size = os.path.getsize(temp_path) if os.path.exists(temp_path) else 0 + msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, file_size) + except (IOError, OSError) as e: + self.logger.warning(f"Could not acquire file lock: {e}") + + data = { + "agent_version": self.agent_version, + "memories": self._memories, + } + json.dump(data, f, indent=2, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + + # Atomic rename + os.replace(temp_path, self._memory_file_path) + self.logger.debug(f"Saved {len(self._memories)} memories to {self._memory_file_path}") + except (IOError, OSError) as e: + self.logger.warning(f"Error saving memories to {self._memory_file_path}: {e}") + + def is_enabled(self) -> bool: + """Check if memory is enabled and available.""" + return self.enabled + + def extract_agent_id(self, input_data: typing.Any) -> str: + """Extract agent_id from input_data.""" + if isinstance(input_data, dict): + return input_data.get(self.agent_id_key, "") + return "" + + async def search_memories( + self, + query: str, + input_data: typing.Any, + limit: typing.Optional[int] = None, + ) -> typing.List[dict]: + """ + Search for relevant memories. + + Returns memory summaries (title, context, tags) for LLM tool-based retrieval. + + Args: + query: Search query. + input_data: Input data containing agent_id. + limit: Maximum memories to retrieve (defaults to search_limit). + + Returns: + List of memory dictionaries with 'memory' and 'metadata' keys for compatibility. + """ + if not self.is_enabled(): + return [] + + try: + limit = limit or self.search_limit + + # TODO: Implement Embedding-Based Search for better semantic matching + # - Use sentence-transformers with 'all-MiniLM-L6-v2' model + # - Generate embeddings when storing memories + # - Calculate cosine similarity for search queries + # - See plan documentation for detailed implementation guide + + # For now, return all memories as summaries (LLM will filter via tools) + # Sort by importance_score and confidence_score (highest first) + sorted_memories = sorted( + self._memories, + key=lambda m: ( + m.get("importance_score", 0.5) * 0.6 + + m.get("confidence_score", 0.5) * 0.4 + ), + reverse=True + ) + + # Return summaries (limit applied by LLM tool) + results = [] + for mem in sorted_memories[:limit]: + results.append({ + "memory": mem.get("content", ""), + "metadata": { + "id": mem.get("id"), + "title": mem.get("title", ""), + "context": mem.get("context", ""), + "category": mem.get("category", DEFAULT_CATEGORY), + "tags": mem.get("tags", []), + "importance_score": mem.get("importance_score", DEFAULT_IMPORTANCE_SCORE), + "confidence_score": mem.get("confidence_score", DEFAULT_CONFIDENCE_SCORE), + } + }) + + if results: + self.logger.debug(f"Retrieved {len(results)} memory summaries") + return results + except Exception as e: + self.logger.warning(f"Error searching memories: {e}") + return [] + + def _truncate_content( + self, + title: str, + context: str, + content: str, + title_max_length: typing.Optional[int] = None, + context_max_length: typing.Optional[int] = None, + content_max_length: typing.Optional[int] = None, + ) -> typing.Tuple[str, str, str]: + """ + Truncate title, context, and content to fit within limits. + + Uses smart truncation at word/sentence boundaries as fallback. + Content should already be concise and generic from memory agent generation. + + Args: + title: Title to truncate + context: Context to truncate + content: Full content to truncate + title_max_length: Maximum length for title (uses constant if None) + context_max_length: Maximum length for context (uses constant if None) + content_max_length: Maximum length for content (uses constant if None) + + Returns: + Tuple of (truncated_title, truncated_context, truncated_content) + """ + title_max = title_max_length or MEMORY_TITLE_MAX_LENGTH + context_max = context_max_length or MEMORY_CONTEXT_MAX_LENGTH + content_max = content_max_length or MEMORY_CONTENT_MAX_LENGTH + + # Truncate title if needed + if len(title) > title_max: + truncated_title = title[:title_max] + last_space = truncated_title.rfind(' ') + if last_space > title_max * 0.7: + title = truncated_title[:last_space].strip() + else: + title = truncated_title.strip() + + # Truncate context if needed + if len(context) > context_max: + truncated_context = context[:context_max] + last_space = truncated_context.rfind(' ') + if last_space > context_max * 0.7: + context = truncated_context[:last_space].strip() + else: + context = truncated_context.strip() + + # Truncate content if needed + if len(content) > content_max: + truncated = content[:content_max] + # Try to truncate at sentence boundary + last_period = truncated.rfind('.') + last_newline = truncated.rfind('\n') + last_break = max(last_period, last_newline) + if last_break > content_max * 0.7: + content = truncated[:last_break + 1].strip() + else: + content = truncated.strip() + + return title, context, content + + async def store_memory( + self, + messages: typing.List[dict], + input_data: typing.Any, + output: typing.Any = None, + metadata: typing.Optional[dict] = None, + ) -> None: + """ + Store memories from agent execution. + + Args: + messages: Conversation messages (user + assistant). + input_data: Input data for context. + output: Optional agent output. + metadata: Optional metadata to attach. + """ + if not self.is_enabled() or not self.storage_enabled: + return + + try: + agent_id = self.extract_agent_id(input_data) + + # Extract title and context from metadata if provided, otherwise generate from messages + user_message = None + assistant_message = None + for msg in messages: + if msg.get("role") == "user": + user_message = msg.get("content", "") + elif msg.get("role") == "assistant": + assistant_message = msg.get("content", "") + + # Use title from metadata if provided, otherwise generate from user message + if metadata and metadata.get("title"): + title = metadata.get("title") + else: + title = (user_message[:50] if user_message else "Memory") if user_message else "Memory" + + # Use context from metadata if provided, otherwise generate from agent_id + if metadata and metadata.get("context"): + context = metadata.get("context") + else: + context = f"Agent execution context" + if agent_id: + context += f" for agent_id: {agent_id}" + + # Build content from messages + # If metadata has title/context, it's likely instructional content - use user_message directly + # Otherwise, format as conversation + if metadata and (metadata.get("title") or metadata.get("context")): + # Instructional content - use user_message directly without "User: " prefix + content = user_message if user_message else "" + else: + # Regular conversation memory - format with prefixes + content_parts = [] + if user_message: + content_parts.append(f"User: {user_message}") + if assistant_message: + content_parts.append(f"Assistant: {assistant_message}") + if output is not None: + if isinstance(output, dict): + content_parts.append(f"Output: {json.dumps(output, indent=2, default=str)}") + else: + content_parts.append(f"Output: {str(output)}") + content = "\n".join(content_parts) + + # Truncate content if needed to fit within limits (fallback safety check) + # Content should already be concise from memory agent generation + title, context, content = self._truncate_content( + title=title, + context=context, + content=content + ) + + # Extract category and tags from metadata + category = metadata.get("category", DEFAULT_CATEGORY) if metadata else DEFAULT_CATEGORY + tags = metadata.get("tags", []) if metadata else [] + importance_score = metadata.get("importance_score", DEFAULT_IMPORTANCE_SCORE) if metadata else DEFAULT_IMPORTANCE_SCORE + + # Create and validate MemoryStorageModel + try: + memory_model = models.MemoryStorageModel( + title=title, + context=context, + content=content, + category=category, + tags=tags, + importance_score=importance_score, + confidence_score=DEFAULT_CONFIDENCE_SCORE, + ) + except pydantic.ValidationError as e: + self.logger.error(f"Memory validation failed: {e}") + raise + + # Create memory dict from validated model + base_metadata = { + self.agent_id_key: agent_id, + "use_count": 0, + } + + extra_metadata = {} + if metadata: + for key, value in metadata.items(): + if key not in { + "category", + "tags", + "importance_score", + "confidence_score", + "title", + "context", + }: + extra_metadata[key] = value + memory = { + "id": uuid.uuid4().hex, + "title": memory_model.title, + "context": memory_model.context, + "content": memory_model.content, + "category": memory_model.category, + "tags": memory_model.tags, + "importance_score": memory_model.importance_score, + "confidence_score": memory_model.confidence_score, + "metadata": { + **base_metadata, + **extra_metadata, + }, + } + + self._memories.append(memory) + + # Prune if needed + if len(self._memories) > self.max_memories: + self._prune_memories() + + self._save_memories() + self.logger.debug("Stored memory") + except Exception as e: + self.logger.warning(f"Error storing memory: {e}") + + def format_memories_for_prompt(self, memories: typing.List[dict]) -> str: + """ + Format memories for inclusion in prompts. + + Args: + memories: List of memory dictionaries. + + Returns: + Formatted string with memories, or empty string if none. + """ + if not memories: + return "" + + memory_lines = [] + for mem in memories: + memory_text = mem.get("memory", "") + metadata = mem.get("metadata", {}) + if memory_text: + title = metadata.get("title", "") + context = metadata.get("context", "") + tags = metadata.get("tags", []) + category = metadata.get("category", "") + importance = metadata.get("importance_score", 0.5) + confidence = metadata.get("confidence_score", 0.5) + use_count = metadata.get("use_count", 0) + + line = f"- {memory_text}" + if title: + line = f"## {title}\n{line}" + if context: + line += f"\n Context: {context}" + if category: + line += f"\n Category: {category}" + if tags: + line += f"\n Tags: {', '.join(tags)}" + line += f"\n Importance: {importance}, Confidence: {confidence}, Used: {use_count} times" + memory_lines.append(line) + + if memory_lines: + return "\n".join(memory_lines) + return "" + + async def store_execution_memory( + self, + input_data: typing.Any, + output: typing.Any, + user_message: typing.Optional[str] = None, + assistant_message: typing.Optional[str] = None, + metadata: typing.Optional[dict] = None, + ) -> None: + """ + Convenience method to store memory from agent execution. + + Automatically builds messages from input_data and output if not provided. + + Args: + input_data: The input data that was processed. + output: The agent's output/result. + user_message: Optional user message (auto-built if not provided). + assistant_message: Optional assistant message (auto-built if not provided). + metadata: Optional metadata to attach. + """ + if not self.is_enabled() or not self.storage_enabled: + return + + # Build messages if not provided + messages = [] + if user_message: + messages.append({"role": "user", "content": user_message}) + elif isinstance(input_data, dict): + # Auto-build user message from input_data + user_content = json.dumps(input_data, indent=2, default=str)[:500] + messages.append({"role": "user", "content": user_content}) + + if assistant_message: + messages.append({"role": "assistant", "content": assistant_message}) + elif output is not None: + # Auto-build assistant message from output + if isinstance(output, dict): + assistant_content = json.dumps(output, indent=2, default=str)[:500] + else: + assistant_content = str(output)[:500] + messages.append({"role": "assistant", "content": assistant_content}) + + if messages: + await self.store_memory(messages, input_data, output, metadata) + + def _prune_memories(self) -> None: + """Remove memories when limit exceeded using priority scoring.""" + if len(self._memories) <= self.max_memories: + return + + # Calculate priority score for each memory + def priority_score(mem: dict) -> float: + importance = mem.get("importance_score", DEFAULT_IMPORTANCE_SCORE) + confidence = mem.get("confidence_score", DEFAULT_CONFIDENCE_SCORE) + use_count = mem.get("metadata", {}).get("use_count", 0) + return (importance * 0.4) + (confidence * 0.3) + (use_count / 100.0 * 0.3) + + # Sort by priority (lowest first) + sorted_memories = sorted(self._memories, key=priority_score) + + # Remove lowest priority memories, but never prune critical ones (importance >= 0.9) + to_remove = [] + for mem in sorted_memories: + if len(self._memories) - len(to_remove) <= self.max_memories: + break + if mem.get("importance_score", 0) < 0.9: + to_remove.append(mem) + + # Remove from memories list + for mem in to_remove: + self._memories.remove(mem) + + if to_remove: + self.logger.info(f"Pruned {len(to_remove)} memories (kept {len(self._memories)})") + + def update_memory_importance(self, memory_id: str, score: float) -> None: + """Update importance score for a memory.""" + for mem in self._memories: + if mem.get("id") == memory_id: + mem["importance_score"] = max(0.0, min(1.0, score)) + self._save_memories() + return + self.logger.warning(f"Memory {memory_id} not found for importance update") + + def update_memory_confidence(self, memory_id: str, score: float) -> None: + """Update confidence score for a memory.""" + for mem in self._memories: + if mem.get("id") == memory_id: + mem["confidence_score"] = max(0.0, min(1.0, score)) + self._save_memories() + return + self.logger.warning(f"Memory {memory_id} not found for confidence update") + + def increment_memory_use(self, memory_id: str) -> None: + """Increment use_count for a memory.""" + for mem in self._memories: + if mem.get("id") == memory_id: + metadata = mem.setdefault("metadata", {}) + metadata["use_count"] = metadata.get("use_count", 0) + 1 + self._save_memories() + return + self.logger.warning(f"Memory {memory_id} not found for use count increment") + + def get_memory_by_id(self, memory_id: str) -> typing.Optional[dict]: + """Get a memory by its ID.""" + for mem in self._memories: + if mem.get("id") == memory_id: + return mem + return None + + def get_all_memories(self) -> typing.List[dict]: + """Get all memories (for summaries).""" + return self._memories.copy() + + @property + def agent_version(self) -> str: + """Get the agent version.""" + return self._agent_version + + @property + def max_memories(self) -> int: + """Get the maximum number of memories.""" + return self._max_memories diff --git a/packages/agents/octobot_agents/storage/memory/tools.py b/packages/agents/octobot_agents/storage/memory/tools.py new file mode 100644 index 000000000..938b5ae79 --- /dev/null +++ b/packages/agents/octobot_agents/storage/memory/tools.py @@ -0,0 +1,151 @@ +# 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 . +import typing + +from octobot_services.services.abstract_ai_service import AbstractAIService + +from octobot_agents.storage.memory.abstract_memory_storage import AbstractMemoryStorage + + +def get_memory_tools(memory_manager: AbstractMemoryStorage, ai_service: AbstractAIService) -> typing.List[dict]: + """ + Get memory tool definitions for LLM function calling. + + Args: + memory_manager: The memory manager instance. + + Returns: + List of tool definitions in OpenAI function calling format. + """ + if not memory_manager or not memory_manager.is_enabled(): + return [] + + return [ + ai_service.format_tool_definition( + name="get_memory_summaries", + description="Get a list of available memories with summaries (id, title, context, tags, category, importance, confidence). Use this to see what memories are available before fetching specific ones.", + parameters={ + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Optional filter by memory category", + }, + "min_importance": { + "type": "number", + "description": "Optional minimum importance score (0.0-1.0)", + }, + }, + }, + ), + ai_service.format_tool_definition( + name="get_memory_by_id", + description="Get the full content of a specific memory by its UUID. Use this after getting memory summaries to fetch detailed information.", + parameters={ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The UUID of the memory to fetch", + }, + }, + "required": ["id"], + }, + ), + ] + + +def execute_memory_tool( + memory_manager: AbstractMemoryStorage, + tool_name: str, + arguments: dict, +) -> typing.Any: + """ + Execute a memory tool call. + + Args: + memory_manager: The memory manager instance. + tool_name: Name of the tool to execute. + arguments: Tool arguments. + + Returns: + Tool execution result. + """ + if not memory_manager or not memory_manager.is_enabled(): + return {"error": "Memory is not enabled"} + + try: + if tool_name == "get_memory_summaries": + category = arguments.get("category") + min_importance = arguments.get("min_importance") + + all_memories = memory_manager.get_all_memories() + + # Filter by category if provided + if category: + all_memories = [m for m in all_memories if m.get("category") == category] + + # Filter by importance if provided + if min_importance is not None: + all_memories = [ + m for m in all_memories + if m.get("importance_score", 0.5) >= min_importance + ] + + # Return summaries + summaries = [] + for mem in all_memories: + summaries.append({ + "id": mem.get("id"), + "title": mem.get("title", ""), + "context": mem.get("context", ""), + "tags": mem.get("tags", []), + "category": mem.get("category", "general"), + "importance_score": mem.get("importance_score", 0.5), + "confidence_score": mem.get("confidence_score", 0.5), + }) + + return summaries + + elif tool_name == "get_memory_by_id": + memory_id = arguments.get("id") + if not memory_id: + return {"error": "Memory ID is required"} + + memory = memory_manager.get_memory_by_id(memory_id) + if not memory: + return {"error": f"Memory with ID {memory_id} not found"} + + # Increment use count + memory_manager.increment_memory_use(memory_id) + + return { + "id": memory.get("id"), + "title": memory.get("title", ""), + "context": memory.get("context", ""), + "content": memory.get("content", ""), + "category": memory.get("category", "general"), + "tags": memory.get("tags", []), + "importance_score": memory.get("importance_score", 0.5), + "confidence_score": memory.get("confidence_score", 0.5), + "metadata": memory.get("metadata", {}), + } + + else: + return {"error": f"Unknown tool: {tool_name}"} + + except Exception as e: + return {"error": str(e)} diff --git a/packages/agents/octobot_agents/team/__init__.py b/packages/agents/octobot_agents/team/__init__.py new file mode 100644 index 000000000..87838f4af --- /dev/null +++ b/packages/agents/octobot_agents/team/__init__.py @@ -0,0 +1,55 @@ +# 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 import channels +from octobot_agents.team.channels import ( + AbstractAgentsTeamChannel, + AbstractAgentsTeamChannelProducer, + AbstractAgentsTeamChannelConsumer, + AbstractSyncAgentsTeamChannelProducer, + AbstractLiveAgentsTeamChannelProducer, +) + +from octobot_agents.team import critic +from octobot_agents.team.critic import AbstractCriticAgent + +from octobot_agents.team import judge +from octobot_agents.team.judge import AbstractJudgeAgent + +from octobot_agents.team import manager +from octobot_agents.team.manager import ( + AbstractTeamManagerAgent, +) + +from octobot_agents.constants import ( + MODIFICATION_ADDITIONAL_INSTRUCTIONS, + MODIFICATION_CUSTOM_PROMPT, + MODIFICATION_EXECUTION_HINTS, +) + +__all__ = [ + "AbstractAgentsTeamChannel", + "AbstractAgentsTeamChannelProducer", + "AbstractAgentsTeamChannelConsumer", + "AbstractSyncAgentsTeamChannelProducer", + "AbstractLiveAgentsTeamChannelProducer", + "AbstractTeamManagerAgent", + "MODIFICATION_ADDITIONAL_INSTRUCTIONS", + "MODIFICATION_CUSTOM_PROMPT", + "MODIFICATION_EXECUTION_HINTS", + "AbstractCriticAgent", + "AbstractJudgeAgent", +] diff --git a/packages/agents/octobot_agents/team/channels/__init__.py b/packages/agents/octobot_agents/team/channels/__init__.py new file mode 100644 index 000000000..eb055fb25 --- /dev/null +++ b/packages/agents/octobot_agents/team/channels/__init__.py @@ -0,0 +1,34 @@ +# 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.channels.agents_team import ( + AbstractAgentsTeamChannel, + AbstractAgentsTeamChannelConsumer, + AbstractAgentsTeamChannelProducer, +) + +from octobot_agents.team.channels.ai_agents_team import ( + AbstractSyncAgentsTeamChannelProducer, + AbstractLiveAgentsTeamChannelProducer, +) + +__all__ = [ + "AbstractAgentsTeamChannel", + "AbstractAgentsTeamChannelConsumer", + "AbstractAgentsTeamChannelProducer", + "AbstractSyncAgentsTeamChannelProducer", + "AbstractLiveAgentsTeamChannelProducer", +] diff --git a/packages/agents/octobot_agents/team/channels/agents_team.py b/packages/agents/octobot_agents/team/channels/agents_team.py new file mode 100644 index 000000000..440e0c9c3 --- /dev/null +++ b/packages/agents/octobot_agents/team/channels/agents_team.py @@ -0,0 +1,686 @@ +# 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 agents 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 +""" +import abc +import typing +from collections import defaultdict + +import octobot_commons.logging as logging + +from octobot_agents.constants import AGENT_NAME_KEY, AGENT_ID_KEY, RESULT_KEY +from octobot_agents.agent import ( + AbstractAgentChannel, + AbstractAgentChannelConsumer, + AbstractAgentChannelProducer, + AbstractAIAgentChannelProducer, +) +from octobot_agents.team.manager import AbstractTeamManagerAgent +from octobot_agents.team.critic import AbstractCriticAgent +from octobot_agents.team.judge import AbstractJudgeAgent +from octobot_agents.agent.memory.channels import AbstractMemoryAgent +from octobot_agents.enums import JudgeDecisionType, StepType +from octobot_agents.errors import MissingManagerError +import octobot_agents.models as models +from octobot_agents.constants import ( + MODIFICATION_ADDITIONAL_INSTRUCTIONS, + MODIFICATION_CUSTOM_PROMPT, + MODIFICATION_EXECUTION_HINTS, +) +import octobot_services.services.abstract_ai_service as abstract_ai_service + +class AbstractAgentsTeamChannelConsumer(AbstractAgentChannelConsumer): + """ + Consumer for team outputs. + + Can be used to consume results from a team's final output channel. + """ + __metaclass__ = abc.ABCMeta + + +class AbstractAgentsTeamChannelProducer(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: + - AbstractSyncAgentsTeamChannelProducer: Direct one-shot execution + - AbstractLiveAgentsTeamChannelProducer: 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["AbstractAgentsTeamChannel"]] = None + TEAM_CONSUMER: typing.Optional[typing.Type[AbstractAgentsTeamChannelConsumer]] = None + TEAM_NAME: str = "AbstractAgentsTeam" + + # Class attributes for critic, memory, manager, and judge agent classes + # Teams can override these to specify which implementations to use + # If None, the feature is disabled + CriticAgentClass: typing.Optional[typing.Type["AbstractCriticAgent"]] = None + MemoryAgentClass: typing.Optional[typing.Type["AbstractMemoryAgent"]] = None + ManagerAgentClass: typing.Optional[typing.Type[AbstractTeamManagerAgent]] = None + JudgeAgentClass: typing.Optional[typing.Type["AbstractJudgeAgent"]] = None + + def __init__( + self, + channel: typing.Optional["AbstractAgentsTeamChannel"], + agents: typing.List[AbstractAIAgentChannelProducer], + relations: typing.List[typing.Tuple[typing.Type[AbstractAgentChannel], typing.Type[AbstractAgentChannel]]], + ai_service: abstract_ai_service.AbstractAIService, + team_name: typing.Optional[str] = None, + team_id: typing.Optional[str] = None, + manager: typing.Optional[AbstractTeamManagerAgent] = None, + self_improving: bool = False, + critic_agent: typing.Optional[AbstractCriticAgent] = None, + memory_agent: typing.Optional[AbstractMemoryAgent] = None, + judge_agent: typing.Optional[AbstractJudgeAgent] = 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, uses ManagerAgentClass if defined. + Raises MissingManagerError if both are None. + self_improving: Whether to enable automatic critic and memory update after execution. + critic_agent: Optional critic agent. If None and self_improving=True, uses CriticAgentClass if defined. + memory_agent: Optional memory agent. If None and self_improving=True, uses MemoryAgentClass if defined. + judge_agent: Optional judge agent for debate phases. If None, uses JudgeAgentClass if defined. + """ + 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 class attribute if not provided + if manager is None: + if self.ManagerAgentClass is not None: + self.manager = self.ManagerAgentClass(channel=None) + else: + raise MissingManagerError( + f"{self.__class__.__name__} requires a manager. " + f"Either set ManagerAgentClass class attribute or pass manager parameter." + ) + else: + self.manager = manager + + # Initialize self-improving mechanism + self.self_improving = self_improving + if self_improving: + if critic_agent is None: + if self.CriticAgentClass is not None: + self.critic_agent = self.CriticAgentClass(channel=None) + else: + self.critic_agent = None + else: + self.critic_agent = critic_agent + + if memory_agent is None: + if self.MemoryAgentClass is not None: + self.memory_agent = self.MemoryAgentClass(channel=None) + else: + self.memory_agent = None + else: + self.memory_agent = memory_agent + else: + self.critic_agent = critic_agent + self.memory_agent = memory_agent + + # Judge agent for debate phases (optional) + if judge_agent is None and self.JudgeAgentClass is not None: + self.judge_agent = self.JudgeAgentClass() + if self.judge_agent.logger is None: + self.judge_agent.logger = self.logger + else: + self.judge_agent = judge_agent + + self.last_execution_plan: typing.Optional[models.ExecutionPlan] = None + self.last_execution_results: typing.Dict[str, typing.Any] = {} + self.last_debate_state: typing.Optional[typing.Dict[str, typing.Any]] = None # debate_history, judge_decisions for logging + + 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.name] = agent + + def get_manager(self) -> typing.Optional[AbstractTeamManagerAgent]: + """ + Get the team manager. + + Returns: + The team manager agent, or None if not set. + """ + return self.manager + + def get_agent_by_name(self, name: str) -> typing.Optional[AbstractAIAgentChannelProducer]: + """ + Get an agent by name. + + Args: + name: The name of the agent to retrieve. + + Returns: + The agent producer if found, None otherwise. + """ + return self._producer_by_name.get(name) + + 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] + + @staticmethod + def _get_debate_message(result: typing.Union[typing.Dict[str, typing.Any], typing.Any]) -> str: + """Extract message text from a debator result (dict or object with message/reasoning).""" + if isinstance(result, dict): + return result.get("message", result.get("reasoning", result.get("content", str(result)))) + msg = getattr(result, "message", None) + if msg is not None: + return str(msg) + reasoning = getattr(result, "reasoning", None) + if reasoning is not None: + return str(reasoning) + return str(result) + + async def _run_debate( + self, + debate_config: "models.DebatePhaseConfig", + initial_data: typing.Dict[str, typing.Any], + results: typing.Dict[str, typing.Dict[str, typing.Any]], + completed_agents: typing.Set[str], + incoming_edges: typing.Dict[typing.Type[AbstractAgentChannel], typing.List[typing.Type[AbstractAgentChannel]]], + ) -> typing.Tuple[typing.Dict[str, typing.Dict[str, typing.Any]], typing.Set[str]]: + """ + Run a debate phase: debators take turns each round, then judge decides continue or exit. + + Updates results and completed_agents. Sets self.last_debate_state with debate_history + and judge_decisions for structured logging. + """ + if self.judge_agent is None: + self.logger.warning("Debate step requires a judge agent but none is configured; skipping debate.") + return results, completed_agents + + debate_history: typing.List[typing.Dict[str, typing.Any]] = [] + judge_decisions: typing.List[typing.Dict[str, typing.Any]] = [] + debator_names = list(debate_config.debator_agent_names) + max_rounds = debate_config.max_rounds + judge_name = debate_config.judge_agent_name + + for round_num in range(1, max_rounds + 1): + # Run each debator in order this round + for debator_name in debator_names: + agent = self._producer_by_name.get(debator_name) + if agent is None: + self.logger.warning(f"Debator {debator_name} not found in team; skipping.") + continue + # Build input: initial state + debate history so far + agent_input: typing.Dict[str, typing.Any] = { + "_debate_history": debate_history, + "_debate_round": round_num, + } + if isinstance(initial_data, dict): + agent_input["_initial_state"] = initial_data + # Predecessor outputs for DAG semantics + channel_type = agent.AGENT_CHANNEL + predecessors = [] + if channel_type is not None: + predecessors = incoming_edges.get(channel_type, []) + for pred_channel in predecessors: + pred_agent = self._producer_by_channel.get(pred_channel) + if pred_agent and pred_agent.name in results: + pred_result = results[pred_agent.name] + agent_input[pred_agent.name] = { + AGENT_NAME_KEY: pred_agent.name, + AGENT_ID_KEY: "", + RESULT_KEY: pred_result.get(RESULT_KEY), + } + if not agent_input.get("_initial_state") and not predecessors: + agent_input = initial_data if isinstance(initial_data, dict) else agent_input + + try: + result = await agent.execute(agent_input, self.ai_service) + except Exception as e: + self.logger.exception(f"Debator {debator_name} execution failed: {e}") + raise + # Extract message text for debate history (agent-specific) + message = self._get_debate_message(result) + debate_history.append({ + "agent_name": debator_name, + "message": str(message), + "round": round_num, + }) + results[debator_name] = { + AGENT_NAME_KEY: debator_name, + AGENT_ID_KEY: "", + RESULT_KEY: result, + } + completed_agents.add(debator_name) + + # Run judge + judge_input = { + "debate_history": debate_history, + "debator_agent_names": debator_names, + "current_round": round_num, + "max_rounds": max_rounds, + } + if isinstance(initial_data, dict): + judge_input["_initial_state"] = initial_data + try: + judge_out = await self.judge_agent.execute(judge_input, self.ai_service) + except Exception as e: + self.logger.exception(f"Judge execution failed: {e}") + raise + if isinstance(judge_out, dict): + judge_dict = judge_out + else: + _dump = getattr(judge_out, "model_dump", None) or getattr(judge_out, "dict", None) + judge_dict = _dump() if _dump else {"decision": JudgeDecisionType.EXIT.value, "reasoning": str(judge_out), "summary": None} + judge_decisions.append({ + "round": round_num, + "decision": judge_dict.get("decision", JudgeDecisionType.EXIT.value), + "reasoning": judge_dict.get("reasoning", ""), + "summary": judge_dict.get("summary"), + }) + if self.logger: + self.logger.debug( + f"Debate round {round_num}: judge decision={judge_dict.get('decision', 'exit')} " + f"reasoning={judge_dict.get('reasoning', '')[:100]}..." + ) + if judge_dict.get("decision") == JudgeDecisionType.EXIT.value or round_num >= max_rounds: + break + + self.last_debate_state = { + "debate_history": debate_history, + "judge_decisions": judge_decisions, + } + return results, completed_agents + + async def _execute_plan( + self, + execution_plan: models.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 + + # Debate step: run debators and judge with rounds + step_type = step.step_type or StepType.AGENT.value + debate_config = step.debate_config + if step_type == StepType.DEBATE.value and debate_config is not None: + results, completed_agents = await self._run_debate( + debate_config, initial_data, results, completed_agents, incoming_edges + ) + 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.name in results: + pred_result = results[pred_agent.name] + agent_input[pred_agent.name] = { + AGENT_NAME_KEY: pred_agent.name, + AGENT_ID_KEY: "", + RESULT_KEY: pred_result.get(RESULT_KEY), + } + + # Store initial_data in a special key for agents that need it (like distribution agent) + # This allows agents to access initial state without breaking agents that expect only predecessor outputs + if isinstance(initial_data, dict): + agent_input["_initial_state"] = initial_data + + self.logger.debug(f"Executing agent: {agent.name}") + try: + result = await agent.execute(agent_input, self.ai_service) + results[agent.name] = { + AGENT_NAME_KEY: agent.name, + AGENT_ID_KEY: "", + RESULT_KEY: result, + } + completed_agents.add(agent.name) + except Exception as e: + self.logger.error(f"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 and all agent outputs for critic + terminal_results: typing.Dict[str, typing.Any] = {} + all_agent_outputs: typing.Dict[str, typing.Any] = {} + for agent in self.agents: + if agent.name in results: + agent_result = results[agent.name].get(RESULT_KEY) + all_agent_outputs[agent.name] = agent_result + if agent in terminal_agents: + terminal_results[agent.name] = agent_result + + # Store for self-improvement + self.last_execution_results = all_agent_outputs + + return terminal_results + + def _get_agent_outputs_from_execution(self) -> typing.Dict[str, typing.Any]: + """ + Collect agent outputs from last execution. + + Returns: + Dict mapping agent names to their outputs. + """ + outputs = {} + for agent in self.agents: + # Try to get output from execution results + if agent.name in self.last_execution_results: + result = self.last_execution_results[agent.name] + try: + # Try dict access + outputs[agent.name] = result.get("result", result) + except AttributeError: + # Not a dict, use directly + outputs[agent.name] = result + + # Include manager output if manager is an AI agent with memory enabled + manager = self.get_manager() + if manager is not None: + try: + if manager.has_memory_enabled(): + # Manager's output is the execution plan + if self.last_execution_plan is not None: + outputs[manager.name] = self.last_execution_plan + except AttributeError: + # Manager is not an AI agent (no has_memory_enabled method) + pass + + return outputs + + async def _self_improve_in_background(self, execution_results: typing.Dict[str, typing.Any]) -> None: + """ + Run critic and memory update in background without blocking. + + Args: + execution_results: Results from team execution. + """ + try: + # 1. Run critic agent + # Manager is already included in agent_outputs via _get_agent_outputs_from_execution() + critic_input = { + "team_producer": self, + "execution_plan": self.last_execution_plan, + "execution_results": execution_results, + "agent_outputs": self._get_agent_outputs_from_execution(), + "execution_metadata": { + "team_name": self.team_name, + "team_id": self.team_id, + }, + } + critic_analysis = await self.critic_agent.execute(critic_input, self.ai_service) + + # 2. Run memory agent with critic output (only for agents needing improvements) + memory_input = { + "critic_analysis": critic_analysis, # Contains agent_improvements dict + "agent_outputs": self._get_agent_outputs_from_execution(), + "execution_metadata": { + "execution_plan": self.last_execution_plan, + "team_name": self.team_name, + "team_producer": self, + }, + } + memory_operation = await self.memory_agent.execute(memory_input, self.ai_service) + + self.logger.debug( + f"Self-improvement completed: {memory_operation.message}. " + f"Processed {len(memory_operation.agents_processed)} agents, " + f"skipped {len(memory_operation.agents_skipped)} agents" + ) + except Exception as e: + self.logger.warning(f"Self-improvement failed (non-blocking): {e}") + + @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({ + AGENT_NAME_KEY: team_name, + AGENT_ID_KEY: agent_id or self.team_id, + RESULT_KEY: result, + }) + + +class AbstractAgentsTeamChannel(AbstractAgentChannel): + """ + Channel for team outputs. + + Allows teams to be composed - one team's output can feed another team. + """ + __metaclass__ = abc.ABCMeta + + PRODUCER_CLASS = AbstractAgentsTeamChannelProducer + CONSUMER_CLASS = AbstractAgentsTeamChannelConsumer diff --git a/packages/agents/octobot_agents/team/channels/ai_agents_team.py b/packages/agents/octobot_agents/team/channels/ai_agents_team.py new file mode 100644 index 000000000..44cd48403 --- /dev/null +++ b/packages/agents/octobot_agents/team/channels/ai_agents_team.py @@ -0,0 +1,406 @@ +# 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 . +import asyncio +import typing + +from octobot_agents.agent import ( + AbstractAIAgentChannel, + AbstractAIAgentChannelProducer, + AbstractAIAgentChannelConsumer, +) +from octobot_agents.constants import AGENT_NAME_KEY, AGENT_ID_KEY, RESULT_KEY +from octobot_agents.team.manager import AbstractTeamManagerAgent +from octobot_agents.team.critic import AbstractCriticAgent +from octobot_agents.agent.memory.channels import AbstractMemoryAgent +from octobot_agents.team.channels.agents_team import ( + AbstractAgentsTeamChannel, + AbstractAgentsTeamChannelProducer, +) +from octobot_agents.errors import AgentConfigurationError +import octobot_services.services.abstract_ai_service as abstract_ai_service +import octobot_agents.models as models + + +class AbstractSyncAgentsTeamChannelProducer(AbstractAgentsTeamChannelProducer): + """ + 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 + """ + + def __init__( + self, + channel: typing.Optional[AbstractAgentsTeamChannel], + agents: typing.List[AbstractAIAgentChannelProducer], + relations: typing.List[typing.Tuple[typing.Type[AbstractAIAgentChannel], typing.Type[AbstractAIAgentChannel]]], + ai_service: abstract_ai_service.AbstractAIService, + team_name: typing.Optional[str] = None, + team_id: typing.Optional[str] = None, + manager: typing.Optional[AbstractTeamManagerAgent] = None, + self_improving: bool = False, + critic_agent: typing.Optional[AbstractCriticAgent] = None, + memory_agent: typing.Optional[AbstractMemoryAgent] = None, + judge_agent: typing.Optional["AbstractJudgeAgent"] = None, + ): + """ + Initialize the sync AI team producer. + + Uses CriticAgentClass / JudgeAgentClass attributes if defined, otherwise disabled. + """ + # Call parent init first - it handles critic/memory/judge agent instantiation via class attributes + super().__init__( + channel=channel, + agents=agents, + relations=relations, + ai_service=ai_service, + team_name=team_name, + team_id=team_id, + manager=manager, + self_improving=self_improving, + critic_agent=critic_agent, + memory_agent=memory_agent, + judge_agent=judge_agent, + ) + + 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 + 4. Trigger self-improvement in background if enabled + + 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 or terminal results from manager + manager_result = await self.manager.execute(manager_input, self.ai_service) + + if isinstance(manager_result, models.ExecutionPlan): + # Plan-driven manager: execute the plan + self.last_execution_plan = manager_result + terminal_results = await self._execute_plan(manager_result, initial_data) + elif isinstance(manager_result, models.ManagerResult): + # Tools-driven manager: extract results from ManagerResult model + terminal_results = manager_result.results + self.last_execution_plan = None + + self.last_execution_results = terminal_results + + 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) + + # Trigger self-improvement in background if enabled + if self.self_improving and self.critic_agent and self.memory_agent: + asyncio.create_task(self._self_improve_in_background(terminal_results)) + + return terminal_results + + +class AbstractLiveAgentsTeamChannelProducer(AbstractAgentsTeamChannelProducer): + """ + 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[AbstractAgentsTeamChannel], + agents: typing.List[AbstractAIAgentChannelProducer], + relations: typing.List[typing.Tuple[typing.Type[AbstractAIAgentChannel], typing.Type[AbstractAIAgentChannel]]], + ai_service: abstract_ai_service.AbstractAIService, + team_name: typing.Optional[str] = None, + team_id: typing.Optional[str] = None, + manager: typing.Optional[AbstractTeamManagerAgent] = None, + self_improving: bool = False, + critic_agent: typing.Optional[AbstractCriticAgent] = None, + memory_agent: typing.Optional[AbstractMemoryAgent] = None, + judge_agent: typing.Optional["AbstractJudgeAgent"] = None, + ): + """ + Initialize the live AI team producer. + + Uses CriticAgentClass / JudgeAgentClass attribute if defined, otherwise disabled. + """ + # Call parent init - it handles critic/memory/judge agent instantiation via class attributes + super().__init__( + channel=channel, + agents=agents, + relations=relations, + ai_service=ai_service, + team_name=team_name, + team_id=team_id, + manager=manager, + self_improving=self_improving, + critic_agent=critic_agent, + memory_agent=memory_agent, + judge_agent=judge_agent, + ) + + # Live-specific state + self._channels: typing.Dict[typing.Type[AbstractAIAgentChannel], AbstractAIAgentChannel] = {} + 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 AgentConfigurationError(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, + ) + + # Register consumer on source channel + await source_channel.new_consumer( + consumer_instance=consumer_instance, + agent_name=self._producer_by_channel[source_channel_type].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.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[AbstractAIAgentChannel], + ) -> 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(AGENT_NAME_KEY, "unknown") + source_id = data.get(AGENT_ID_KEY, "") + result = data.get(RESULT_KEY) + + # Store with both name and id for full context + received_inputs[source_name] = { + AGENT_NAME_KEY: source_name, + AGENT_ID_KEY: source_id, + RESULT_KEY: result, + } + + self.logger.debug( + f"Target {target_producer.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.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.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(RESULT_KEY) + self._terminal_results[terminal_agent.name] = result + + self.logger.debug( + f"Terminal agent {terminal_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.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") + + # Store execution results for self-improvement + self.last_execution_results = self._terminal_results.copy() + + # Push team result if we have a channel + if self.channel is not None: + await self.push(self._terminal_results) + + # Trigger self-improvement in background if enabled + if self.self_improving and self.critic_agent and self.memory_agent: + asyncio.create_task(self._self_improve_in_background(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") diff --git a/packages/agents/octobot_agents/team/critic/__init__.py b/packages/agents/octobot_agents/team/critic/__init__.py new file mode 100644 index 000000000..553b44110 --- /dev/null +++ b/packages/agents/octobot_agents/team/critic/__init__.py @@ -0,0 +1,36 @@ +# 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.critic.channels.critic_agent import ( + AbstractCriticAgent, + CriticAgentChannel, + CriticAgentConsumer, + CriticAgentProducer, + AICriticAgentChannel, + AICriticAgentConsumer, + AICriticAgentProducer, +) + + +__all__ = [ + "AbstractCriticAgent", + "CriticAgentChannel", + "CriticAgentConsumer", + "CriticAgentProducer", + "AICriticAgentChannel", + "AICriticAgentConsumer", + "AICriticAgentProducer", +] diff --git a/packages/agents/octobot_agents/team/critic/channels/__init__.py b/packages/agents/octobot_agents/team/critic/channels/__init__.py new file mode 100644 index 000000000..ba75da288 --- /dev/null +++ b/packages/agents/octobot_agents/team/critic/channels/__init__.py @@ -0,0 +1,35 @@ +# 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.critic.channels.critic_agent import ( + AbstractCriticAgent, + CriticAgentChannel, + CriticAgentConsumer, + CriticAgentProducer, + AICriticAgentChannel, + AICriticAgentConsumer, + AICriticAgentProducer, +) + +__all__ = [ + "AbstractCriticAgent", + "CriticAgentChannel", + "CriticAgentConsumer", + "CriticAgentProducer", + "AICriticAgentChannel", + "AICriticAgentConsumer", + "AICriticAgentProducer", +] diff --git a/packages/agents/octobot_agents/team/critic/channels/critic_agent.py b/packages/agents/octobot_agents/team/critic/channels/critic_agent.py new file mode 100644 index 000000000..77847fa13 --- /dev/null +++ b/packages/agents/octobot_agents/team/critic/channels/critic_agent.py @@ -0,0 +1,132 @@ +# 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 . +import abc +import typing + +import octobot_commons.logging as logging + +import octobot_agents.models as models +from octobot_agents.agent.channels.agent import ( + AbstractAgentChannel, + AbstractAgentChannelConsumer, + AbstractAgentChannelProducer, +) +from octobot_agents.agent.channels.ai_agent import ( + AbstractAIAgentChannel, + AbstractAIAgentChannelConsumer, + AbstractAIAgentChannelProducer, +) +import octobot_services.services.abstract_ai_service as abstract_ai_service + + +class AbstractCriticAgent(abc.ABC): + """ + Base interface for all critic agents. + + Critic agents analyze team execution to find issues, improvements, errors, and inconsistencies. + """ + + def __init__(self, self_improving: bool = True): + """Initialize the critic agent.""" + self.self_improving = self_improving + self.logger = None # Will be set by subclasses + + @abc.abstractmethod + async def execute( + self, + input_data: typing.Union[models.CriticInput, typing.Dict[str, typing.Any]], + ai_service: abstract_ai_service.AbstractAIService + ) -> models.CriticAnalysis: + """ + Execute critic analysis of team execution. + + Args: + input_data: Contains {"team_producer": team_producer, "execution_plan": ExecutionPlan, "execution_results": Dict, "agent_outputs": Dict, "execution_metadata": dict} + ai_service: The AI service instance (for AI critic agents) + + Returns: + CriticAnalysis with issues, improvements, errors, inconsistencies, and agent_improvements + """ + raise NotImplementedError("execute must be implemented by subclasses") + + +# ----------------------------------------------------------------------------- +# Base (non-AI) channel classes +# ----------------------------------------------------------------------------- + + +class CriticAgentChannel(AbstractAgentChannel): + """Base channel for critic agents.""" + __slots__ = () + OUTPUT_SCHEMA = models.CriticAnalysis + + +class CriticAgentConsumer(AbstractAgentChannelConsumer): + """Base consumer for critic agent channels.""" + __slots__ = () + + +class CriticAgentProducer(AbstractAgentChannelProducer, AbstractCriticAgent): + """Base producer for critic agents. Subclasses implement execute().""" + __slots__ = () + + AGENT_CHANNEL = CriticAgentChannel + AGENT_CONSUMER = CriticAgentConsumer + + def __init__(self, channel: typing.Optional[CriticAgentChannel] = None, self_improving: bool = True): + AbstractCriticAgent.__init__(self, self_improving=self_improving) + AbstractAgentChannelProducer.__init__(self, channel) + self.name = self.__class__.__name__ + self.logger = logging.get_logger(self.__class__.__name__) + + +# ----------------------------------------------------------------------------- +# AI channel classes (inherit from base AND AI abstracts) +# ----------------------------------------------------------------------------- + + +class AICriticAgentChannel(CriticAgentChannel, AbstractAIAgentChannel): + """AI channel for critic agents.""" + __slots__ = () + + +class AICriticAgentConsumer(CriticAgentConsumer, AbstractAIAgentChannelConsumer): + """AI consumer for critic agent channels.""" + __slots__ = () + + +class AICriticAgentProducer(CriticAgentProducer, AbstractAIAgentChannelProducer): + """AI producer for critic agents. Tentacles extend this and implement execute() with LLM.""" + __slots__ = () + + AGENT_CHANNEL = AICriticAgentChannel + AGENT_CONSUMER = AICriticAgentConsumer + + def __init__( + self, + channel: typing.Optional[AICriticAgentChannel] = None, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + self_improving: bool = True, + **kwargs, + ): + AbstractCriticAgent.__init__(self, self_improving=self_improving) + AbstractAIAgentChannelProducer.__init__( + self, channel, model=model, max_tokens=max_tokens, temperature=temperature, **kwargs + ) + self.name = self.__class__.__name__ + self.logger = logging.get_logger(self.__class__.__name__) diff --git a/packages/agents/octobot_agents/team/judge/__init__.py b/packages/agents/octobot_agents/team/judge/__init__.py new file mode 100644 index 000000000..46767b9b7 --- /dev/null +++ b/packages/agents/octobot_agents/team/judge/__init__.py @@ -0,0 +1,35 @@ +# 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.judge.channels.judge_agent import ( + AbstractJudgeAgent, + JudgeAgentChannel, + JudgeAgentConsumer, + JudgeAgentProducer, + AIJudgeAgentChannel, + AIJudgeAgentConsumer, + AIJudgeAgentProducer, +) + +__all__ = [ + "AbstractJudgeAgent", + "JudgeAgentChannel", + "JudgeAgentConsumer", + "JudgeAgentProducer", + "AIJudgeAgentChannel", + "AIJudgeAgentConsumer", + "AIJudgeAgentProducer", +] diff --git a/packages/agents/octobot_agents/team/judge/channels/__init__.py b/packages/agents/octobot_agents/team/judge/channels/__init__.py new file mode 100644 index 000000000..46767b9b7 --- /dev/null +++ b/packages/agents/octobot_agents/team/judge/channels/__init__.py @@ -0,0 +1,35 @@ +# 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.judge.channels.judge_agent import ( + AbstractJudgeAgent, + JudgeAgentChannel, + JudgeAgentConsumer, + JudgeAgentProducer, + AIJudgeAgentChannel, + AIJudgeAgentConsumer, + AIJudgeAgentProducer, +) + +__all__ = [ + "AbstractJudgeAgent", + "JudgeAgentChannel", + "JudgeAgentConsumer", + "JudgeAgentProducer", + "AIJudgeAgentChannel", + "AIJudgeAgentConsumer", + "AIJudgeAgentProducer", +] diff --git a/packages/agents/octobot_agents/team/judge/channels/judge_agent.py b/packages/agents/octobot_agents/team/judge/channels/judge_agent.py new file mode 100644 index 000000000..d8addb501 --- /dev/null +++ b/packages/agents/octobot_agents/team/judge/channels/judge_agent.py @@ -0,0 +1,135 @@ +# 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 judge agent interface and base channel classes for debate phases. + +Judge agents decide whether a debate should continue or exit and optionally +provide a synthesis summary when exiting. +""" +import abc +import typing + +import octobot_commons.logging as logging + +import octobot_agents.models as models +from octobot_agents.agent.channels.agent import ( + AbstractAgentChannel, + AbstractAgentChannelConsumer, + AbstractAgentChannelProducer, +) +from octobot_agents.agent.channels.ai_agent import ( + AbstractAIAgentChannel, + AbstractAIAgentChannelConsumer, + AbstractAIAgentChannelProducer, +) +import octobot_services.services.abstract_ai_service as abstract_ai_service + + +class AbstractJudgeAgent(abc.ABC): + """ + Base interface for all judge agents. + + Judge agents are used in debate phases: they receive debate history + (messages from debator agents) and decide whether to continue the debate + or exit with an optional synthesis summary. + """ + + def __init__(self): + """Initialize the judge agent.""" + self.logger = None # Will be set by subclasses + + @abc.abstractmethod + async def execute( + self, + input_data: typing.Union[typing.Dict[str, typing.Any], models.JudgeInput], + ai_service: abstract_ai_service.AbstractAIService, + ) -> models.JudgeDecision: + """ + Execute judge decision on debate state. + + Args: + input_data: Contains debate_history (list of {agent_name, message, round}), + debator_agent_names, current_round, max_rounds, and optional + _initial_state for context. + ai_service: The AI service instance (for AI judge agents). + + Returns: + JudgeDecision with decision ("continue" or "exit"), reasoning, and optional summary. + """ + raise NotImplementedError("execute must be implemented by subclasses") + + +class JudgeAgentChannel(AbstractAgentChannel): + """Base channel for judge agents.""" + __slots__ = () + OUTPUT_SCHEMA = models.JudgeDecision + + +class JudgeAgentConsumer(AbstractAgentChannelConsumer): + """Base consumer for judge agent channels.""" + __slots__ = () + + +class JudgeAgentProducer(AbstractAgentChannelProducer, AbstractJudgeAgent): + """Base producer for judge agents. Subclasses implement execute().""" + __slots__ = () + + AGENT_CHANNEL = JudgeAgentChannel + AGENT_CONSUMER = JudgeAgentConsumer + + def __init__(self, channel: typing.Optional[JudgeAgentChannel] = None): + AbstractJudgeAgent.__init__(self) + AbstractAgentChannelProducer.__init__(self, channel) + self.name = self.__class__.__name__ + self.logger = logging.get_logger(self.__class__.__name__) + + +# ----------------------------------------------------------------------------- +# AI channel classes (inherit from base AND AI abstracts) +# ----------------------------------------------------------------------------- + + +class AIJudgeAgentChannel(JudgeAgentChannel, AbstractAIAgentChannel): + """AI channel for judge agents.""" + __slots__ = () + + +class AIJudgeAgentConsumer(JudgeAgentConsumer, AbstractAIAgentChannelConsumer): + """AI consumer for judge agent channels.""" + __slots__ = () + + +class AIJudgeAgentProducer(JudgeAgentProducer, AbstractAIAgentChannelProducer): + """AI producer for judge agents. Tentacles extend this and implement execute() with LLM.""" + __slots__ = () + + AGENT_CHANNEL = AIJudgeAgentChannel + AGENT_CONSUMER = AIJudgeAgentConsumer + + def __init__( + self, + channel: typing.Optional[AIJudgeAgentChannel] = None, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + **kwargs, + ): + AbstractJudgeAgent.__init__(self) + AbstractAIAgentChannelProducer.__init__( + self, channel, model=model, max_tokens=max_tokens, temperature=temperature, **kwargs + ) + self.name = self.__class__.__name__ + self.logger = logging.get_logger(self.__class__.__name__) diff --git a/packages/agents/octobot_agents/team/manager/__init__.py b/packages/agents/octobot_agents/team/manager/__init__.py new file mode 100644 index 000000000..21f67eca5 --- /dev/null +++ b/packages/agents/octobot_agents/team/manager/__init__.py @@ -0,0 +1,47 @@ +# 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.manager.channels.manager_agent import ( + AbstractTeamManagerAgent, + ManagerAgentChannel, + ManagerAgentConsumer, + ManagerAgentProducer, + AIManagerAgentChannel, + AIManagerAgentConsumer, + AIManagerAgentProducer, + AIPlanManagerAgentChannel, + AIPlanManagerAgentConsumer, + AIPlanManagerAgentProducer, + AIToolsManagerAgentChannel, + AIToolsManagerAgentConsumer, + AIToolsManagerAgentProducer, +) + +__all__ = [ + "AbstractTeamManagerAgent", + "ManagerAgentChannel", + "ManagerAgentConsumer", + "ManagerAgentProducer", + "AIManagerAgentChannel", + "AIManagerAgentConsumer", + "AIManagerAgentProducer", + "AIPlanManagerAgentChannel", + "AIPlanManagerAgentConsumer", + "AIPlanManagerAgentProducer", + "AIToolsManagerAgentChannel", + "AIToolsManagerAgentConsumer", + "AIToolsManagerAgentProducer", +] diff --git a/packages/agents/octobot_agents/team/manager/channels/__init__.py b/packages/agents/octobot_agents/team/manager/channels/__init__.py new file mode 100644 index 000000000..21f67eca5 --- /dev/null +++ b/packages/agents/octobot_agents/team/manager/channels/__init__.py @@ -0,0 +1,47 @@ +# 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.manager.channels.manager_agent import ( + AbstractTeamManagerAgent, + ManagerAgentChannel, + ManagerAgentConsumer, + ManagerAgentProducer, + AIManagerAgentChannel, + AIManagerAgentConsumer, + AIManagerAgentProducer, + AIPlanManagerAgentChannel, + AIPlanManagerAgentConsumer, + AIPlanManagerAgentProducer, + AIToolsManagerAgentChannel, + AIToolsManagerAgentConsumer, + AIToolsManagerAgentProducer, +) + +__all__ = [ + "AbstractTeamManagerAgent", + "ManagerAgentChannel", + "ManagerAgentConsumer", + "ManagerAgentProducer", + "AIManagerAgentChannel", + "AIManagerAgentConsumer", + "AIManagerAgentProducer", + "AIPlanManagerAgentChannel", + "AIPlanManagerAgentConsumer", + "AIPlanManagerAgentProducer", + "AIToolsManagerAgentChannel", + "AIToolsManagerAgentConsumer", + "AIToolsManagerAgentProducer", +] diff --git a/packages/agents/octobot_agents/team/manager/channels/manager_agent.py b/packages/agents/octobot_agents/team/manager/channels/manager_agent.py new file mode 100644 index 000000000..907ca9f64 --- /dev/null +++ b/packages/agents/octobot_agents/team/manager/channels/manager_agent.py @@ -0,0 +1,461 @@ +# 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 . +import abc +import typing +from typing import TYPE_CHECKING, Dict, Optional + +import octobot_commons.logging as logging + +from octobot_services.services.abstract_ai_service import AbstractAIService + +from octobot_agents.agent.channels.agent import ( + AbstractAgentChannel, + AbstractAgentChannelConsumer, + AbstractAgentChannelProducer, +) +from octobot_agents.agent.channels.ai_agent import ( + AbstractAIAgentChannel, + AbstractAIAgentChannelConsumer, + AbstractAIAgentChannelProducer, +) +from octobot_agents.models import ExecutionPlan +from octobot_agents.models import ( + ManagerState, + ManagerResult, + ManagerToolCall, + RunAgentArgs, + RunDebateArgs, +) +from octobot_agents.constants import ( + TOOL_RUN_AGENT, + TOOL_RUN_DEBATE, + TOOL_FINISH, + AGENT_NAME_KEY, + RESULT_KEY, +) + +if TYPE_CHECKING: + from octobot_agents.models import ManagerInput + from octobot_agents.agent.channels.ai_agent import AbstractAIAgentChannelProducer + + +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.Union["ManagerInput", typing.Dict[str, typing.Any]], + ai_service: typing.Any # AbstractAIService - type not available at runtime + ) -> typing.Union[ExecutionPlan, ManagerResult]: + """ + Execute the manager's logic and return an execution plan or terminal results. + + 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 (plan-driven) or ManagerResult with terminal results (tools-driven) + """ + 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.debug(f"Agent {agent.name} has no channel, skipping instructions") + return + + await agent.channel.modify(**instruction) + + +class ManagerAgentChannel(AbstractAgentChannel): + """Base channel for manager agents.""" + __slots__ = () + OUTPUT_SCHEMA = ExecutionPlan + + +class ManagerAgentConsumer(AbstractAgentChannelConsumer): + """Base consumer for manager agent channels.""" + __slots__ = () + + +class ManagerAgentProducer(AbstractAgentChannelProducer, AbstractTeamManagerAgent): + """Base producer for manager agents. Subclasses implement execute().""" + __slots__ = () + + AGENT_CHANNEL = ManagerAgentChannel + AGENT_CONSUMER = ManagerAgentConsumer + + def __init__(self, channel: Optional[ManagerAgentChannel] = None): + AbstractTeamManagerAgent.__init__(self) + AbstractAgentChannelProducer.__init__(self, channel) + self.name = self.__class__.__name__ + + +class AIManagerAgentChannel(ManagerAgentChannel, AbstractAIAgentChannel): + """AI channel for manager agents.""" + __slots__ = () + + +class AIManagerAgentConsumer(ManagerAgentConsumer, AbstractAIAgentChannelConsumer): + """AI consumer for manager agent channels.""" + __slots__ = () + + +class AIManagerAgentProducer(ManagerAgentProducer, AbstractAIAgentChannelProducer): + """AI producer for manager agents. Tentacles extend this and implement execute() with LLM.""" + __slots__ = () + + AGENT_CHANNEL = AIManagerAgentChannel + AGENT_CONSUMER = AIManagerAgentConsumer + + def __init__( + self, + channel: Optional[AIManagerAgentChannel] = None, + model: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + **kwargs, + ): + AbstractTeamManagerAgent.__init__(self) + AbstractAIAgentChannelProducer.__init__( + self, channel, model=model, max_tokens=max_tokens, temperature=temperature, **kwargs + ) + self.name = self.__class__.__name__ + self.logger = logging.get_logger(self.__class__.__name__) + + +class AIPlanManagerAgentChannel(AIManagerAgentChannel): + """Plan-driven AI channel for manager agents.""" + __slots__ = () + + +class AIPlanManagerAgentConsumer(AIManagerAgentConsumer): + """Plan-driven AI consumer for manager agent channels.""" + __slots__ = () + + +class AIPlanManagerAgentProducer(AIManagerAgentProducer): + """Plan-driven AI producer for manager agents. execute() returns ExecutionPlan.""" + __slots__ = () + + AGENT_CHANNEL = AIPlanManagerAgentChannel + AGENT_CONSUMER = AIPlanManagerAgentConsumer + + def __init__( + self, + channel: Optional[AIPlanManagerAgentChannel] = None, + model: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + **kwargs, + ): + super().__init__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + **kwargs + ) + + +# ----------------------------------------------------------------------------- +# Tools-driven AI manager agent classes +# ----------------------------------------------------------------------------- + + +class AIToolsManagerAgentChannel(AIManagerAgentChannel): + """Tools-driven AI channel for manager agents.""" + __slots__ = () + + +class AIToolsManagerAgentConsumer(AIManagerAgentConsumer): + """Tools-driven AI consumer for manager agent channels.""" + __slots__ = () + + +class AIToolsManagerAgentProducer(AIManagerAgentProducer): + """Tools-driven AI producer for manager agents. execute() returns terminal results with internal tool loop.""" + __slots__ = () + + AGENT_CHANNEL = AIToolsManagerAgentChannel + AGENT_CONSUMER = AIToolsManagerAgentConsumer + + def __init__( + self, + channel: Optional[AIToolsManagerAgentChannel] = None, + model: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + max_tool_calls: Optional[int] = None, + **kwargs, + ): + super().__init__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + **kwargs + ) + self.max_tool_calls = max_tool_calls or 50 + + async def execute( + self, + input_data: typing.Union["ManagerInput", typing.Dict[str, typing.Any]], + ai_service: AbstractAIService + ) -> ManagerResult: + """ + Execute tools-driven management with internal tool loop. + + Returns ManagerResult with terminal results instead of ExecutionPlan. + """ + 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") + + # Initialize state + state = ManagerState( + completed_agents=[], + results={}, + initial_data=initial_data, + tool_call_history=[] + ) + + tool_call_count = 0 + + while tool_call_count < self.max_tool_calls: + # Build context for LLM + context = self._build_tools_context(team_producer, state, instructions) + + # Get tool call from LLM + tool_call = await self._get_tool_call(context, ai_service) + + if tool_call.tool_name == TOOL_FINISH: + # Finish tool called - return current results + break + + # Execute the tool + await self._execute_tool(tool_call, team_producer, state, ai_service) + + tool_call_count += 1 + state.tool_call_history.append(tool_call) + + return ManagerResult( + completed_agents=state.completed_agents, + results=state.results, + tool_calls_used=tool_call_count, + ) + + def _build_tools_context( + self, + team_producer: typing.Any, + state: ManagerState, + instructions: typing.Optional[str] + ) -> typing.Dict[str, typing.Any]: + """Build context dict for LLM tool call.""" + agents_info = [] + for agent in team_producer.agents: + agents_info.append({ + "name": agent.name, + "channel": agent.AGENT_CHANNEL.__name__ if agent.AGENT_CHANNEL else None, + }) + + return { + "team_name": team_producer.team_name, + "agents": agents_info, + "completed_agents": state.completed_agents, + "current_results": state.results, + "initial_data": state.initial_data, + "instructions": instructions, + "tool_call_history": [call.model_dump() for call in state.tool_call_history], + } + + async def _get_tool_call( + self, + context: typing.Dict[str, typing.Any], + ai_service: typing.Any + ) -> ManagerToolCall: + """Get tool call from LLM.""" + messages = [ + {"role": "system", "content": self._get_tools_prompt()}, + {"role": "user", "content": f"Context: {self.format_data(context)}"}, + ] + + tools = [ + ai_service.format_tool_definition( + name=TOOL_RUN_AGENT, + description="Run a specific agent and get its result", + parameters=RunAgentArgs.model_json_schema(), + ), + ai_service.format_tool_definition( + name=TOOL_RUN_DEBATE, + description="Run a debate between agents with a judge", + parameters=RunDebateArgs.model_json_schema(), + ), + ai_service.format_tool_definition( + name=TOOL_FINISH, + description="Finish execution and return current results", + parameters={}, + ), + ] + + response_data = await self._call_llm( + messages, + ai_service, + json_output=True, + tools=tools, + return_tool_calls=True, + ) + + if response_data is None: + raise ValueError("LLM did not return any tool calls. The manager agent requires tool calls to coordinate team execution.") + + return ManagerToolCall.model_validate(response_data) + + def _get_tools_prompt(self) -> str: + """Get the tools system prompt.""" + return """You are a tools-driven team manager responsible for coordinating AI agents to complete tasks. + +Your goal is to analyze the available agents and current context, then use the available tools to execute the appropriate agents in sequence to achieve the team's objective. + +Available tools: +- run_agent: Execute a single agent by name to get its specialized output +- run_debate: Run a debate between multiple agents with a judge to resolve complex decisions +- finish: Complete execution when you have gathered sufficient results + +Important: Always run at least one agent before calling finish. Examine the available agents and determine which ones are needed to complete the task. Start by running key agents to gather information, then call finish when you have comprehensive results.""" + + async def _execute_tool( + self, + tool_call: ManagerToolCall, + team_producer: typing.Any, + state: ManagerState, + ai_service: typing.Any + ) -> None: + """Execute a tool and update state.""" + if tool_call.tool_name == TOOL_RUN_AGENT: + await self._tool_run_agent(tool_call.arguments, team_producer, state, ai_service) + elif tool_call.tool_name == TOOL_RUN_DEBATE: + await self._tool_run_debate(tool_call.arguments, team_producer, state, ai_service) + else: + self.logger.warning(f"Unknown tool: {tool_call.tool_name}") + + async def _tool_run_agent( + self, + args: typing.Dict[str, typing.Any], + team_producer: typing.Any, + state: ManagerState, + ai_service: typing.Any + ) -> None: + """Run a single agent with proper input structure for team execution.""" + run_args = RunAgentArgs.model_validate(args) + agent = team_producer._producer_by_name.get(run_args.agent_name) + + if agent is None: + self.logger.warning(f"Agent {run_args.agent_name} not found") + return + + # Build agent input following team channel structure + # For entry agents: pass initial_data directly + # For non-entry agents: pass dict with predecessor results keyed by agent name + + # Check if agent is an entry agent (has no predecessors in the team) + incoming_edges, _ = team_producer._build_dag() + agent_channel_type = agent.AGENT_CHANNEL + predecessors = incoming_edges.get(agent_channel_type, []) + + if not predecessors: + # Entry agent: receives initial_data directly + agent_input = state.initial_data.copy() + if run_args.instructions: + agent_input["instructions"] = run_args.instructions + else: + # Non-entry agent: receives predecessor results in channel format + agent_input = {} + + # Add each predecessor's result in the expected format + for pred_channel_type in predecessors: + # Find the predecessor agent by channel type + pred_agent = team_producer._producer_by_channel.get(pred_channel_type) + if pred_agent and pred_agent.name in state.results: + pred_result_entry = state.results[pred_agent.name] + agent_input[pred_agent.name] = { + AGENT_NAME_KEY: pred_agent.name, + RESULT_KEY: pred_result_entry.get("result"), + } + + # Also preserve initial_state for agents that need it + agent_input["_initial_state"] = state.initial_data.copy() + + if run_args.instructions: + agent_input["instructions"] = run_args.instructions + + result = await agent.execute(agent_input, ai_service) + + state.completed_agents.append(run_args.agent_name) + state.results[run_args.agent_name] = { + "agent_name": run_args.agent_name, + "result": result, + } + + async def _tool_run_debate( + self, + args: typing.Dict[str, typing.Any], + team_producer: typing.Any, + state: ManagerState, + ai_service: typing.Any + ) -> None: + """Run a debate.""" + debate_args = RunDebateArgs.model_validate(args) + + # Use team's debate method + debate_results, completed = await team_producer._run_debate( + debate_config={ + "debator_agent_names": debate_args.debator_agent_names, + "judge_agent_name": debate_args.judge_agent_name, + "max_rounds": debate_args.max_rounds, + }, + initial_data=state.initial_data, + results=state.results, + completed_agents=set(state.completed_agents), + incoming_edges={}, # Simplified + ) + + # Update state + state.completed_agents.extend(completed - set(state.completed_agents)) + state.results.update(debate_results) diff --git a/packages/agents/tests/__init__.py b/packages/agents/tests/__init__.py new file mode 100644 index 000000000..af5264354 --- /dev/null +++ b/packages/agents/tests/__init__.py @@ -0,0 +1,15 @@ +# 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 . diff --git a/packages/agents/tests/test_agent.py b/packages/agents/tests/test_agent.py new file mode 100644 index 000000000..c25c589cc --- /dev/null +++ b/packages/agents/tests/test_agent.py @@ -0,0 +1,74 @@ +# 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 . +import pytest + +from octobot_agents.agent.channels.ai_agent import AbstractAIAgentChannelProducer +from octobot_agents.agent.channels.agent import AbstractAgentChannel +from octobot_agents.constants import ( + AGENT_DEFAULT_MAX_TOKENS, + AGENT_DEFAULT_TEMPERATURE, + AGENT_DEFAULT_MAX_RETRIES, +) + + +class TestAgentChannel(AbstractAgentChannel): + """Test channel for testing.""" + pass + + +class TestAIAgentProducer(AbstractAIAgentChannelProducer): + """Test agent producer for testing.""" + + AGENT_CHANNEL = TestAgentChannel + + def _get_default_prompt(self) -> str: + return "You are a test agent." + + async def execute(self, input_data, ai_service): + return {"result": "test"} + + +def test_agent_name_is_class_name(): + """Test that agent.name is set to the class name.""" + channel = TestAgentChannel() + agent = TestAIAgentProducer(channel) + + assert agent.name == "TestAIAgentProducer" + assert agent.name == agent.__class__.__name__ + + +def test_agent_default_values(): + """Test that agent uses default values from constants.""" + channel = TestAgentChannel() + agent = TestAIAgentProducer(channel) + + assert agent.max_tokens == AGENT_DEFAULT_MAX_TOKENS + assert agent.temperature == AGENT_DEFAULT_TEMPERATURE + assert agent.MAX_RETRIES == AGENT_DEFAULT_MAX_RETRIES + + +def test_agent_custom_values(): + """Test that agent can override default values.""" + channel = TestAgentChannel() + agent = TestAIAgentProducer( + channel, + max_tokens=5000, + temperature=0.7, + ) + + assert agent.max_tokens == 5000 + assert agent.temperature == 0.7 + assert agent.name == "TestAIAgentProducer" 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 c5969b0c7..e4e9efb20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # Drakkar-Software requirements OctoBot-Commons==1.10.6 -OctoBot-Trading==2.5.4 +OctoBot-Trading==2.5.5 OctoBot-Evaluators==1.10.1 OctoBot-Tentacles-Manager==2.10.0 OctoBot-Services==1.7.2 diff --git a/setup.py b/setup.py index 38fbfefa2..9204dfb8a 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,8 @@ for excluded_package in EXCLUDED_PACKAGES ) ] +# Include octobot_agents from packages/agents/octobot_agents +PACKAGES.extend(find_packages(where='packages/agents')) # long description from README file with open('README.md', encoding='utf-8') as f: @@ -59,6 +61,7 @@ def ignore_git_requirements(requirements): description='Cryptocurrencies alert / trading bot', py_modules=['start'], packages=PACKAGES, + package_dir={'octobot_agents': 'packages/agents/octobot_agents'}, package_data={ "": DATA_FILES, },