diff --git a/concordia/components/agent/__init__.py b/concordia/components/agent/__init__.py index 57bab4a9..613e742b 100644 --- a/concordia/components/agent/__init__.py +++ b/concordia/components/agent/__init__.py @@ -25,6 +25,7 @@ from concordia.components.agent import no_op_context_processor from concordia.components.agent import observation from concordia.components.agent import plan +from concordia.components.agent import puppet_act_component from concordia.components.agent import question_of_query_associated_memories from concordia.components.agent import question_of_recent_memories from concordia.components.agent import relationships diff --git a/concordia/components/agent/puppet_act_component.py b/concordia/components/agent/puppet_act_component.py new file mode 100644 index 00000000..e8e3de09 --- /dev/null +++ b/concordia/components/agent/puppet_act_component.py @@ -0,0 +1,188 @@ +# Copyright 2023 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""An acting component that can be set to give fixed responses.""" + + +from collections.abc import Mapping, Sequence + +from concordia.document import interactive_document +from concordia.language_model import language_model +from concordia.typing import clock as game_clock +from concordia.typing import entity as entity_lib +from concordia.typing import entity_component +from concordia.typing import logging +from concordia.utils import helper_functions +from typing_extensions import override + +DEFAULT_PRE_ACT_KEY = 'Act' + + +class PuppetActComponent(entity_component.ActingComponent): + """A component which concatenates contexts from context components. + + The component will output a fixed response to a pre-specified calls to action. + Otherwise, this component will receive the contexts from `pre_act` from all + the components, and assemble them in the order specified to `__init__`. If the + component order is not specified, then components will be assembled in the + iteration order of the `ComponentContextMapping` passed to + `get_action_attempt`. Components that return empty strings from `pre_act` are + ignored. + """ + + def __init__( + self, + model: language_model.LanguageModel, + clock: game_clock.GameClock, + fixed_responses: Mapping[str, str], + component_order: Sequence[str] | None = None, + pre_act_key: str = DEFAULT_PRE_ACT_KEY, + logging_channel: logging.LoggingChannel = logging.NoOpLoggingChannel, + ): + """Initializes the agent. + + Args: + model: The language model to use for generating the action attempt. + clock: the game clock is needed to know when is the current time + fixed_responses: A mapping from call to action to fixed response. + component_order: The order in which the component contexts will be + assembled when calling the act component. If None, the contexts will be + assembled in the iteration order of the `ComponentContextMapping` passed + to `get_action_attempt`. If the component order is specified, but does + not contain all the components passed to `get_action_attempt`, the + missing components will be appended at the end in the iteration order of + the `ComponentContextMapping` passed to `get_action_attempt`. The same + component cannot appear twice in the component order. All components in + the component order must be in the `ComponentContextMapping` passed to + `get_action_attempt`. + pre_act_key: Prefix to add to the context of the component. + logging_channel: The channel to use for debug logging. + + Raises: + ValueError: If the component order is not None and contains duplicate + components. + """ + self._model = model + self._clock = clock + if component_order is None: + self._component_order = None + else: + self._component_order = tuple(component_order) + if self._component_order is not None: + if len(set(self._component_order)) != len(self._component_order): + raise ValueError( + 'The component order contains duplicate components: ' + + ', '.join(self._component_order) + ) + + self._fixed_responses = fixed_responses + + self._pre_act_key = pre_act_key + self._logging_channel = logging_channel + + def _context_for_action( + self, + contexts: entity_component.ComponentContextMapping, + ) -> str: + if self._component_order is None: + return '\n'.join(context for context in contexts.values() if context) + else: + order = self._component_order + tuple( + sorted(set(contexts.keys()) - set(self._component_order)) + ) + return '\n'.join(contexts[name] for name in order if contexts[name]) + + @override + def get_action_attempt( + self, + contexts: entity_component.ComponentContextMapping, + action_spec: entity_lib.ActionSpec, + ) -> str: + prompt = interactive_document.InteractiveDocument(self._model) + context = self._context_for_action(contexts) + prompt.statement(context + '\n') + + call_to_action = action_spec.call_to_action.format( + name=self.get_entity().name, + timedelta=helper_functions.timedelta_to_readable_str( + self._clock.get_step_size() + ), + ) + + if call_to_action in self._fixed_responses: + print( + f'Using fixed response for {call_to_action}:' + f' {self._fixed_responses[call_to_action]}' + ) + output = self._fixed_responses[call_to_action] + if ( + action_spec.output_type == entity_lib.OutputType.CHOICE + and output not in action_spec.options + ): + raise ValueError( + f'Fixed response {output} not in options: {action_spec.options}' + ) + elif action_spec.output_type == entity_lib.OutputType.FLOAT: + try: + return str(float(output)) + except ValueError: + return '0.0' + + return self._fixed_responses[call_to_action] + + if action_spec.output_type == entity_lib.OutputType.FREE: + output = self.get_entity().name + ' ' + output += prompt.open_question( + call_to_action, + max_tokens=2200, + answer_prefix=output, + # This terminator protects against the model providing extra context + # after the end of a directly spoken response, since it normally + # puts a space after a quotation mark only in these cases. + terminators=('" ', '\n'), + question_label='Exercise', + ) + self._log(output, prompt) + return output + elif action_spec.output_type == entity_lib.OutputType.CHOICE: + idx = prompt.multiple_choice_question( + question=call_to_action, answers=action_spec.options + ) + output = action_spec.options[idx] + self._log(output, prompt) + return output + elif action_spec.output_type == entity_lib.OutputType.FLOAT: + prefix = self.get_entity().name + ' ' + sampled_text = prompt.open_question( + call_to_action, + max_tokens=2200, + answer_prefix=prefix, + ) + self._log(sampled_text, prompt) + try: + return str(float(sampled_text)) + except ValueError: + return '0.0' + else: + raise NotImplementedError( + f'Unsupported output type: {action_spec.output_type}. ' + 'Supported output types are: FREE, CHOICE, and FLOAT.' + ) + + def _log(self, result: str, prompt: interactive_document.InteractiveDocument): + self._logging_channel({ + 'Key': self._pre_act_key, + 'Value': result, + 'Prompt': prompt.view().text().splitlines(), + }) diff --git a/concordia/factory/agent/basic_puppet_agent__supporting_role.py b/concordia/factory/agent/basic_puppet_agent__supporting_role.py new file mode 100644 index 00000000..8b98a3d9 --- /dev/null +++ b/concordia/factory/agent/basic_puppet_agent__supporting_role.py @@ -0,0 +1,196 @@ +# Copyright 2024 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A Generic Agent Factory.""" + +from collections.abc import Mapping +import datetime +import types + +from concordia.agents import entity_agent_with_logging +from concordia.associative_memory import associative_memory +from concordia.associative_memory import formative_memories +from concordia.clocks import game_clock +from concordia.components import agent as agent_components +from concordia.language_model import language_model +from concordia.memory_bank import legacy_associative_memory +from concordia.typing import entity_component +from concordia.utils import measurements as measurements_lib + + +def _get_class_name(object_: object) -> str: + return object_.__class__.__name__ + + +def build_agent( + *, + config: formative_memories.AgentConfig, + model: language_model.LanguageModel, + memory: associative_memory.AssociativeMemory, + clock: game_clock.MultiIntervalClock, + update_time_interval: datetime.timedelta, + fixed_response_by_call_to_action: Mapping[str, str], + additional_components: Mapping[ + entity_component.ComponentName, + entity_component.ContextComponent, + ] = types.MappingProxyType({}), +) -> entity_agent_with_logging.EntityAgentWithLogging: + """Build an agent. + + Args: + config: The agent config to use. + model: The language model to use. + memory: The agent's memory object. + clock: The clock to use. + update_time_interval: Agent calls update every time this interval passes. + fixed_response_by_call_to_action: A mapping from call to action to fixed + response. + additional_components: Additional components to add to the agent. + + Returns: + An agent. + """ + del update_time_interval + if config.extras.get('main_character', False): + raise ValueError('This function is meant for a supporting character ' + 'but it was called on a main character.') + + agent_name = config.name + raw_memory = legacy_associative_memory.AssociativeMemoryBank(memory) + measurements = measurements_lib.Measurements() + + instructions = agent_components.instructions.Instructions( + agent_name=agent_name, + logging_channel=measurements.get_channel('Instructions').on_next, + ) + time_display = agent_components.report_function.ReportFunction( + function=clock.current_time_interval_str, + pre_act_key='\nCurrent time', + logging_channel=measurements.get_channel('TimeDisplay').on_next, + ) + observation_label = '\nObservation' + observation = agent_components.observation.Observation( + clock_now=clock.now, + timeframe=clock.get_step_size(), + pre_act_key=observation_label, + logging_channel=measurements.get_channel('Observation').on_next, + ) + somatic_state_label = '\nSensations and feelings' + somatic_state = agent_components.question_of_query_associated_memories.SomaticStateWithoutPreAct( + model=model, + clock_now=clock.now, + logging_channel=measurements.get_channel('SomaticState').on_next, + pre_act_key=somatic_state_label, + ) + observation_summary_label = '\nSummary of recent observations' + observation_summary = agent_components.observation.ObservationSummary( + model=model, + clock_now=clock.now, + timeframe_delta_from=datetime.timedelta(hours=4), + timeframe_delta_until=datetime.timedelta(hours=0), + components={_get_class_name(somatic_state): somatic_state_label}, + pre_act_key=observation_summary_label, + logging_channel=measurements.get_channel('ObservationSummary').on_next, + ) + relevant_memories_label = '\nRecalled memories and observations' + relevant_memories = agent_components.all_similar_memories.AllSimilarMemories( + model=model, + components={ + _get_class_name(observation_summary): observation_summary_label, + _get_class_name(somatic_state): somatic_state_label, + _get_class_name(time_display): 'The current date/time is', + }, + num_memories_to_retrieve=10, + pre_act_key=relevant_memories_label, + logging_channel=measurements.get_channel('AllSimilarMemories').on_next, + ) + self_perception_label = ( + f'\nQuestion: What kind of person is {agent_name}?\nAnswer') + self_perception = agent_components.question_of_recent_memories.SelfPerception( + model=model, + pre_act_key=self_perception_label, + logging_channel=measurements.get_channel('SelfPerception').on_next, + ) + situation_perception_label = ( + f'\nQuestion: What kind of situation is {agent_name} in ' + 'right now?\nAnswer') + situation_perception = ( + agent_components.question_of_recent_memories.SituationPerception( + model=model, + components={ + _get_class_name(observation): observation_label, + _get_class_name(somatic_state): somatic_state_label, + _get_class_name(observation_summary): observation_summary_label, + _get_class_name(relevant_memories): relevant_memories_label, + }, + clock_now=clock.now, + pre_act_key=situation_perception_label, + logging_channel=measurements.get_channel( + 'SituationPerception' + ).on_next, + ) + ) + person_by_situation_label = ( + f'\nQuestion: What would a person like {agent_name} do in ' + 'a situation like this?\nAnswer') + person_by_situation = ( + agent_components.question_of_recent_memories.PersonBySituation( + model=model, + components={ + _get_class_name(self_perception): self_perception_label, + _get_class_name(situation_perception): situation_perception_label, + }, + clock_now=clock.now, + pre_act_key=person_by_situation_label, + logging_channel=measurements.get_channel('PersonBySituation').on_next, + ) + ) + + entity_components = ( + # Components that provide pre_act context. + instructions, + time_display, + observation, + observation_summary, + relevant_memories, + self_perception, + situation_perception, + person_by_situation, + + # Components that do not provide pre_act context. + somatic_state, + ) + + components_of_agent = {_get_class_name(component): component + for component in entity_components} + components_of_agent[ + agent_components.memory_component.DEFAULT_MEMORY_COMPONENT_NAME] = ( + agent_components.memory_component.MemoryComponent(raw_memory)) + components_of_agent.update(additional_components) + + act_component = agent_components.puppet_act_component.PuppetActComponent( + model=model, + clock=clock, + logging_channel=measurements.get_channel('ActComponent').on_next, + fixed_responses=fixed_response_by_call_to_action, + ) + + agent = entity_agent_with_logging.EntityAgentWithLogging( + agent_name=agent_name, + act_component=act_component, + context_components=components_of_agent, + component_logging=measurements, + ) + + return agent diff --git a/examples/modular/environment/pub_coordination.py b/examples/modular/environment/pub_coordination.py index e7e491de..9299ca8c 100644 --- a/examples/modular/environment/pub_coordination.py +++ b/examples/modular/environment/pub_coordination.py @@ -36,7 +36,7 @@ from examples.modular.environment.modules import pub_coordination_london as pubs_lib from examples.modular.environment.modules import pub_coordination_relationships from concordia.factory.agent import basic_entity_agent__main_role -from concordia.factory.agent import basic_entity_agent__supporting_role +from concordia.factory.agent import basic_puppet_agent__supporting_role from concordia.factory.environment import basic_game_master from concordia.language_model import language_model from concordia.thought_chains import thought_chains as thought_chains_lib @@ -61,8 +61,8 @@ TIME_INCREMENT_BETWEEN_SCENES = datetime.timedelta(hours=24) -NUM_MAIN_PLAYERS = 5 -NUM_SUPPORTING_PLAYERS = 2 +NUM_MAIN_PLAYERS = 4 +NUM_SUPPORTING_PLAYERS = 1 SCENARIO_PREMISE = [ f'The year is {YEAR}. This week is the European football cup.' @@ -96,6 +96,67 @@ def get_shared_memories_and_context() -> tuple[Sequence[str], str]: return shared_memories, shared_context +def configure_player( + name: str, favorite_pub: str, is_main: bool, all_player_names_str: str +) -> formative_memories.AgentConfig: + """Configure a player. + + Args: + name: the name of the player + favorite_pub: the favorite pub of the player + is_main: whether the player is a main character or not + all_player_names_str: the names of all the players in one string + + Returns: + config: the player config + """ + social_classes = ['working', 'middle', 'upper'] + + social_class = random.choice(social_classes) + reasons = random.choice(PUB_PREFERENCES[favorite_pub]) + + extras = { + 'player_specific_memories': [ + f'{name} is a member of the {social_class} class.', + ( + f'{name} supports' + f' {random.choice(pubs_lib.EURO_CUP_COUNTRIES)} in' + ' football.' + ), + ], + 'main_character': is_main, + 'preference': {pub: 1.0 if pub == favorite_pub else 0.8 for pub in PUBS}, + } + + if not is_main: + extras['fixed_response_by_call_to_action'] = { + f'Which pub would {name} go to watch the game?': favorite_pub + } + extras['favourite_pub'] = favorite_pub + + config = formative_memories.AgentConfig( + name=name, + gender=random.choice(['male', 'female']), + date_of_birth=datetime.datetime(year=1980, month=4, day=28), + formative_ages=[16, 20], + goal=( + f'Watch the game in the same pub as {all_player_names_str}.' + f' {name} would prefer {favorite_pub}' + ), + context=( + f"{all_player_names_str}' are best friends.Born in London, {name} has" + f' a favorite pub which is {favorite_pub}. They are also aware of the' + f' following:{reasons}' + ), + traits=( + f"{name}'s personality is like " + + player_traits_and_styles.get_trait(flowery=True) + ), + extras=extras, + ) + return config + + def configure_players() -> tuple[ list[formative_memories.AgentConfig], list[formative_memories.AgentConfig], @@ -108,47 +169,23 @@ def configure_players() -> tuple[ main_player_configs: configs for the main characters supporting_player_configs: configs for the supporting characters """ - social_classes = ['working', 'middle', 'upper'] - names = random.sample(FIRST_NAMES, NUM_MAIN_PLAYERS) + names = random.sample(FIRST_NAMES, NUM_MAIN_PLAYERS + NUM_SUPPORTING_PLAYERS) all_players = ', '.join(names) player_configs = [] for i in range(NUM_MAIN_PLAYERS): name = names[i] - fav_pub = PUBS[i % NUM_PUBS] - - social_class = random.choice(social_classes) - reasons = random.choice(PUB_PREFERENCES[fav_pub]) - config = formative_memories.AgentConfig( - name=name, - gender=random.choice(['male', 'female']), - date_of_birth=datetime.datetime(year=1980, month=4, day=28), - formative_ages=[16, 20], - goal=( - f'Watch the game in the same pub as {all_players}. {name} would' - f' prefer {fav_pub}' - ), - context=( - f"{all_players}' are best friends." - f'Born in London, {name} has a favorite pub which is {fav_pub}.' - f' They are also aware of the following:{reasons}' - ), - traits=( - f"{name}'s personality is like " - + player_traits_and_styles.get_trait(flowery=True) - ), - extras={ - 'player_specific_memories': [ - f'{name} is a member of the {social_class} class.', - ( - f'{name} supports' - f' {random.choice(pubs_lib.EURO_CUP_COUNTRIES)} in' - ' football.' - ), - ], - 'main_character': True, - 'preference': {pub: 1.0 if pub == fav_pub else 0.8 for pub in PUBS}, - }, + favorite_pub = PUBS[i % NUM_PUBS] + config = configure_player( + name, favorite_pub, is_main=True, all_player_names_str=all_players + ) + player_configs.append(config) + + for i in range(NUM_SUPPORTING_PLAYERS): + name = names[NUM_MAIN_PLAYERS + i] + favorite_pub = PUBS[0] + config = configure_player( + name, favorite_pub, is_main=False, all_player_names_str=all_players ) player_configs.append(config) @@ -403,7 +440,7 @@ def configure_scenes( players=players, clock=clock, option_multiplier=option_multiplier, - player_configs=main_player_configs, + player_configs=player_configs, scene_type_name=DECISION_SCENE_TYPE, ) coordination_payoffs.append(this_coordination_payoff) @@ -412,7 +449,7 @@ def configure_scenes( scene_lib.SceneSpec( scene_type=scene_specs['social'], start_time=START_TIME + i * TIME_INCREMENT_BETWEEN_SCENES, - participant_configs=main_player_configs, + participant_configs=player_configs, num_rounds=1, ), scene_lib.SceneSpec( @@ -420,7 +457,7 @@ def configure_scenes( start_time=START_TIME + i * TIME_INCREMENT_BETWEEN_SCENES + datetime.timedelta(hours=8), - participant_configs=main_player_configs, + participant_configs=player_configs, num_rounds=1, ), ] @@ -595,15 +632,27 @@ def __init__( player_config.name ), ) - player = basic_entity_agent__supporting_role.build_agent( + favorite_pub = player_config.extras['favourite_pub'] + explicit_preference = agent_components.constant.Constant( + pre_act_key='explicit preference', + state=( + f'{player_config.name} will only go to their preferred pub' + f' {favorite_pub} and nowhere else. They are very vocal about it.' + ), + ) + player = basic_puppet_agent__supporting_role.build_agent( config=player_config, model=self._model, memory=self._all_memories[player_config.name], clock=self._clock, update_time_interval=MAJOR_TIME_STEP, additional_components={ - 'Guiding principle of good conversation': conversation_style + 'Guiding principle of good conversation': conversation_style, + 'Explicit preference': explicit_preference, }, + fixed_response_by_call_to_action=player_config.extras[ + 'fixed_response_by_call_to_action' + ], ) supporting_players.append(player)