from __future__ import annotations
import asyncio
import asyncio.runners
import traceback
from abc import ABC, abstractmethod
from typing import Any, Callable, List, Optional, Union
from loguru import logger
from pydantic import BaseModel, ConfigDict
from langchain_core.language_models.base import BaseLanguageModel
from sherpa_ai.actions.base import BaseAction
from sherpa_ai.actions.exceptions import (
SherpaActionExecutionException,
SherpaMissingInformationException,
)
from sherpa_ai.config.task_result import TaskResult
from sherpa_ai.events import Event
from sherpa_ai.memory import Belief, SharedMemory
from sherpa_ai.output_parsers.base import BaseOutputProcessor
from sherpa_ai.policies.base import BasePolicy, PolicyOutput
from sherpa_ai.policies.exceptions import SherpaPolicyException
from sherpa_ai.prompts.prompt_template_loader import PromptTemplate
from sherpa_ai.utils import is_coroutine_function
[docs]
class BaseAgent(ABC, BaseModel):
"""Base class for all agents in the Sherpa AI system.
This abstract class defines the core functionality and interface that all agents
must implement. It provides methods for action selection, execution, validation,
and output synthesis.
Attributes:
name (str): The name of the agent.
description (str): A description of the agent's purpose and capabilities.
shared_memory (SharedMemory): Memory shared between agents for information exchange.
belief (Belief): The agent's internal state and knowledge.
policy (BasePolicy): The policy that determines which actions to take.
num_runs (int): Number of action execution cycles to perform. Defaults to 1.
actions (List[BaseAction]): List of actions the agent can perform.
validation_steps (int): Number of validation attempts per validation. Defaults to 1.
validations (List[BaseOutputProcessor]): List of output validators.
feedback_agent_name (str): Name of the agent providing feedback. Defaults to "critic".
global_regen_max (int): Maximum number of output regeneration attempts. Defaults to 12.
llm (Any): Language model used for text generation.
prompt_template (PromptTemplate): Template for generating prompts.
stop_checker (Callable[[Belief], bool]): Function to determine if execution should stop.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> from sherpa_ai.actions.base import BaseAction
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> print(agent.name)
TestAgent
""" # noqa: E501
model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow")
name: str
description: str
shared_memory: SharedMemory = None
belief: Belief = None
policy: BasePolicy = None
num_runs: int = 1
actions: List[BaseAction] = []
validation_steps: int = 1
validations: List[BaseOutputProcessor] = []
feedback_agent_name: str = "critic"
global_regen_max: int = 12
llm: Optional[BaseLanguageModel] = None
prompt_template: PromptTemplate = None
# Checks whether the execution of the agent should be stopped
# default to never stop
stop_checker: Callable[[Belief], bool] = lambda _: False
if prompt_template is None:
prompt_template = PromptTemplate("prompts/prompts.json")
[docs]
@abstractmethod
def create_actions(self) -> List[BaseAction]:
"""Create and return the list of actions available to this agent.
This method must be implemented by all agent subclasses to define
the specific actions that the agent can perform.
Returns:
List[BaseAction]: List of action objects that the agent can use.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> from sherpa_ai.actions.base import BaseAction
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return [MyCustomAction()]
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> actions = agent.create_actions()
>>> print(len(actions))
1
"""
pass
[docs]
@abstractmethod
def synthesize_output(self) -> str:
"""Generate the final output based on the agent's actions and belief state.
This method must be implemented by all agent subclasses to define
how the agent produces its final output from its internal state.
Returns:
str: The synthesized output string.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "This is my final answer."
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> output = agent.synthesize_output()
>>> print(output)
This is my final answer.
"""
pass
[docs]
def send_event(self, event: str, args: dict):
"""Send an event to the state machine in the belief.
This method dispatches an event to the agent's belief state machine,
allowing the agent to update its internal state based on external events.
Args:
event (str): The name of the event to send.
args (dict): Arguments to pass to the event handler.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> agent.send_event("task_start", {"task_id": "123"})
"""
if self.belief.state_machine is None:
logger.error("State machine is not defined in the belief")
return
getattr(self.belief.state_machine, event)(**args)
[docs]
async def async_send_event(self, event: str, args: dict):
"""Send an event to the state machine in the belief asynchronously.
This method dispatches an event to the agent's belief state machine
asynchronously, allowing for non-blocking state updates.
Args:
event (str): The name of the event to send.
args (dict): Arguments to pass to the event handler.
Example:
>>> import asyncio
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> asyncio.run(agent.async_send_event("task_start", {"task_id": "123"}))
"""
if self.belief.state_machine is None:
logger.error("State machine is not defined in the belief")
return
func = getattr(self.belief.state_machine, event)
if func is None:
logger.error(f"Event {event} is not defined in the state machine")
return
await func(**args)
[docs]
async def async_handle_event(self, event: Event):
"""Handle a specific type of event.
This method processes the event based on its type and updates the agent's
belief state accordingly. To create customized event handling,
override this method in subclasses.
Args:
event (Event): The event to handle.
"""
if event.event_type == "trigger":
# explicit trigger event, send it to the state machine
await self.async_send_event(event.name, event.args)
else:
# add the event to the belief
self.belief.internal_events.append(event)
[docs]
def agent_preparation(self):
"""Prepare the agent for execution.
This method initializes the agent's state, observes the shared memory,
and sets up the agent's actions if they haven't been defined yet.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> agent.agent_preparation()
"""
logger.debug(f"```⏳{self.name} is thinking...```")
if len(self.belief.get_actions()) == 0 and self.belief.state_machine is None:
actions = self.actions if len(self.actions) > 0 else self.create_actions()
self.belief.set_actions(actions)
[docs]
def select_action(self) -> Optional[PolicyOutput]:
"""Select the next action to execute based on the current belief state.
This method uses the agent's policy to determine which action to take
next based on the current state of the agent's belief.
Returns:
Optional[PolicyOutput]: The selected action and its arguments, or None if
no action could be selected.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> action = agent.select_action()
>>> print(action is None)
True
"""
try:
result = self.policy.select_action(self.belief)
return result
except SherpaPolicyException as e:
self.belief.update_internal(
"action_start",
self.feedback_agent_name,
outputs=f"Error in selecting action: {e}",
)
logger.exception(e)
return None
except Exception as e:
logger.exception(e)
return e
[docs]
async def async_select_action(self) -> Optional[PolicyOutput]:
"""Select the next action to execute asynchronously.
This method uses the agent's policy to determine which action to take
next based on the current state of the agent's belief, in an asynchronous manner.
Returns:
Optional[PolicyOutput]: The selected action and its arguments, or None if
no action could be selected.
Example:
>>> import asyncio
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> action = asyncio.run(agent.async_select_action())
>>> print(action is None)
True
""" # noqa: E501
try:
result = await self.policy.async_select_action(self.belief)
return result
except SherpaPolicyException as e:
self.belief.update_internal(
"action_finish",
self.feedback_agent_name,
outputs=f"Error in selecting action: {e}",
)
logger.exception(e)
return None
except Exception as e:
logger.exception(e)
return e
[docs]
async def agent_finished(self, result: str) -> str:
"""Process the final result after all actions have been executed.
This method validates the output if validators are present, logs the result,
and stores it in the shared memory.
Args:
result (str): The final result to process.
Returns:
str: The processed result.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> final_result = agent.agent_finished("My answer")
>>> print(final_result)
My answer
"""
if len(self.validations) > 0:
result = self.validate_output()
logger.debug(f"```🤖{self.name} wrote: {result}```")
if self.shared_memory is not None:
await self.shared_memory.async_add("result", self.name, content=result)
return result
[docs]
def run(self) -> TaskResult:
"""Run the agent synchronously.
This method executes the agent's action selection and execution loop
synchronously, returning a TaskResult with the final output.
Returns:
TaskResult: The result of the agent's execution.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> result = agent.run()
>>> print(result.status)
success
"""
return asyncio.run(self.async_run())
[docs]
async def async_run(self) -> TaskResult:
"""Run the agent asynchronously.
This method executes the agent's action selection and execution loop
asynchronously, returning a TaskResult with the final output.
Returns:
TaskResult: The result of the agent's execution.
Example:
>>> import asyncio
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> result = asyncio.run(agent.async_run())
>>> print(result.status)
success
"""
logger.debug(f"```⏳{self.name} is thinking...```")
actions = await self.belief.async_get_actions()
if len(actions) == 0 and self.belief.state_machine is None:
actions = self.actions if len(self.actions) > 0 else self.create_actions()
self.belief.set_actions(actions)
action_output = None
for _ in range(self.num_runs):
actions = await self.belief.async_get_actions()
if len(actions) == 0:
break
result = await self.async_select_action()
if result is None:
# this means no action is selected
continue
elif isinstance(result, Exception):
tb_exception = traceback.TracebackException.from_exception(result)
stack_trace = "".join(tb_exception.format())
task_result = TaskResult(content=stack_trace, status="failed")
return task_result
logger.debug(f"Action selected: {result}")
logger.debug(
f"🤖{self.name} is executing```" "``` {result.action.name}...```"
)
action_output = await self.async_act(result.action, result.args)
if action_output is None:
continue
elif isinstance(action_output, SherpaMissingInformationException):
question = action_output.message
task_result = TaskResult(content=question, status="waiting")
return task_result
action_output = self.belief.get(result.action.name, action_output)
if self.stop_checker(self.belief):
task_result = TaskResult(content=action_output, status="waiting")
return task_result
logger.debug(f"```Action output: {action_output}```")
action_output = await self.agent_finished(action_output)
task_result = TaskResult(content=action_output, status="success")
return task_result
[docs]
def validation_iterator(
self,
validations,
global_regen_count,
all_pass,
validation_is_scaped,
result,
):
"""Iterate through validations and process their results.
This method processes each validation in sequence, updating the belief
and regenerating output if necessary.
Args:
validations: List of validators to process.
global_regen_count: Current count of regeneration attempts.
all_pass: Whether all validations have passed so far.
validation_is_scaped: Whether any validation has been skipped.
result: Current result to validate.
Returns:
tuple: Updated values for global_regen_count, all_pass, validation_is_scaped, and result.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> count, passed, escaped, output = agent.validation_iterator(
... validations=[],
... global_regen_count=0,
... all_pass=False,
... validation_is_scaped=False,
... result="Test output"
... )
>>> print(count)
0
""" # noqa: E501
for i in range(len(validations)):
validation = validations[i]
logger.info(f"validation_running: {validation.__class__.__name__}")
logger.info(f"validation_count: {validation.count}")
# this checks if the validator has already exceeded the validation steps
# limit.
if validation.count < self.validation_steps:
self.belief.update_internal("result", self.name, content=result)
validation_result = validation.process_output(
text=result, belief=self.belief, llm=self.llm
)
logger.info(f"validation_result: {validation_result}")
if not validation_result.is_valid:
self.belief.update_internal(
"feedback",
self.feedback_agent_name,
content=validation_result.feedback,
)
result = self.synthesize_output()
global_regen_count += 1
break
# if all validations passed then set all_pass to True
elif i == len(validations) - 1:
result = validation_result.result
all_pass = True
else:
result = validation_result.result
# if validation is the last one and surpassed the validation steps limit
# then finish the loop with all_pass and mention there is a scaped
# validation.
elif i == len(validations) - 1:
validation_is_scaped = True
all_pass = True
else:
# if the validation has already reached the validation steps limit then
# continue to the next validation.
validation_is_scaped = True
return global_regen_count, all_pass, validation_is_scaped, result
[docs]
def validate_output(self):
"""Validate the synthesized output through a series of validation steps.
This method iterates through each validation in the 'validations' list, and for
each validation, it performs 'validation_steps' attempts to synthesize output
using 'synthesize_output' method. If the output doesn't pass validation,
feedback is incorporated into the belief system.
If a validation fails after all attempts, the error messages from the last
failed validation are appended to the final result.
Returns:
str: The synthesized output after validation.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return []
... def synthesize_output(self) -> str:
... return "Validated output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> result = agent.validate_output()
>>> print(result)
Validated output
"""
result = ""
# create array of instance of validation so that we can keep track of how many
# times regeneration happened.
all_pass = False
validation_is_scaped = False
iteration_count = 0
result = self.synthesize_output()
global_regen_count = 0
# reset the state of all the validation before starting the validation process.
for validation in self.validations:
validation.reset_state()
validations = self.validations
# this loop will run until max regeneration reached or all validations have
# failed
while self.global_regen_max > global_regen_count and not all_pass:
logger.info(f"validations_size: {len(validations)}")
iteration_count += 1
logger.info(f"main_iteration: {iteration_count}")
logger.info(f"regen_count: {global_regen_count}")
logger.info("result: " + result)
(
global_regen_count,
all_pass,
validation_is_scaped,
result,
) = self.validation_iterator(
all_pass=all_pass,
global_regen_count=global_regen_count,
validation_is_scaped=validation_is_scaped,
validations=validations,
result=result,
)
# if all didn't pass or validation reached max regeneration run the validation
# one more time but no regeneration.
if validation_is_scaped or self.global_regen_max >= global_regen_count:
failed_validations = []
for validation in validations:
validation_result = validation.process_output(
text=result, belief=self.belief, llm=self.llm
)
if not validation_result.is_valid:
failed_validations.append(validation)
else:
result = validation_result.result
result += "\n".join(
failed_validation.get_failure_message()
for failed_validation in failed_validations
)
else:
# check if validation is not passed after all the attempts if so return the
# error message.
result += "\n".join(
(
inst_val.get_failure_message()
if inst_val.count == self.validation_steps
else ""
)
for inst_val in validations
)
self.belief.update_internal("result", self.name, content=result)
return result
[docs]
def act(self, action: BaseAction, inputs: dict) -> Union[Optional[str], Exception]:
"""Execute an action synchronously.
This method executes the specified action with the given inputs,
handling any exceptions that may occur during execution.
Args:
action (BaseAction): The action to execute.
inputs (dict): Input parameters for the action.
Returns:
Union[Optional[str], Exception]: The result of the action execution,
or an exception if execution failed.
Example:
>>> from sherpa_ai.agents.base import BaseAgent
>>> from sherpa_ai.actions.base import BaseAction
>>> class MyAction(BaseAction):
... def execute(self, **kwargs):
... return "Action executed"
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return [MyAction()]
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> result = agent.act(MyAction(), {"param": "value"})
>>> print(result)
Action executed
"""
asyncio.run(self.async_act(action, inputs))
[docs]
async def async_act(self, action: BaseAction, inputs: dict) -> Optional[str]:
"""Execute an action asynchronously.
This method executes the specified action with the given inputs asynchronously,
handling any exceptions that may occur during execution.
Args:
action (BaseAction): The action to execute.
inputs (dict): Input parameters for the action.
Returns:
Optional[str]: The result of the action execution, or None if execution failed.
Example:
>>> import asyncio
>>> from sherpa_ai.agents.base import BaseAgent
>>> from sherpa_ai.actions.base import BaseAction
>>> class MyAction(BaseAction):
... async def execute(self, **kwargs):
... return "Action executed"
>>> class MyAgent(BaseAgent):
... def create_actions(self) -> List[BaseAction]:
... return [MyAction()]
... def synthesize_output(self) -> str:
... return "Output"
>>> agent = MyAgent(name="TestAgent", description="A test agent")
>>> result = asyncio.run(agent.async_act(MyAction(), {"param": "value"}))
>>> print(result)
Action executed
""" # noqa: E501
try:
if is_coroutine_function(action):
action_output = await action(**inputs)
else:
action_output = action(**inputs)
return action_output
except SherpaActionExecutionException as e:
self.belief.update_internal(
"action_finish",
self.feedback_agent_name,
outputs=f"Error in executing action: {action.name}. Error: {e}",
)
return None
def __hash__(self):
"""Make BaseAgent hashable based on its name.
Returns:
int: Hash value for the agent
Example:
>>> agent1 = MyAgent(name="Agent1", description="First agent")
>>> agent2 = MyAgent(name="Agent2", description="Second agent")
>>> agent_set = {agent1, agent2}
>>> len(agent_set)
2
"""
return hash(self.name)
def __eq__(self, other):
"""Define equality for BaseAgent based on name.
Args:
other: Object to compare with
Returns:
bool: True if the objects are equal, False otherwise
Example:
>>> agent1 = MyAgent(name="Agent", description="First instance")
>>> agent2 = MyAgent(name="Agent", description="Second instance")
>>> agent1 == agent2
True
"""
if not isinstance(other, BaseAgent):
return False
return self.name == other.name