diff --git a/concordia/contrib/components/agent/choice_of_component_test.py b/concordia/contrib/components/agent/choice_of_component_test.py new file mode 100644 index 000000000..8c78a2159 --- /dev/null +++ b/concordia/contrib/components/agent/choice_of_component_test.py @@ -0,0 +1,85 @@ +"""Tests for choice_of_component module.""" + +from unittest import mock + +from absl.testing import absltest +from concordia.contrib.components.agent import choice_of_component +from concordia.language_model import no_language_model +from concordia.typing import entity_component + + +class ChoiceOfComponentTest(absltest.TestCase): + """Tests selection and delegation behavior for choice components.""" + + def _make_entity(self, component_map): + entity = mock.MagicMock() + entity.name = 'Alice' + entity.get_phase.return_value = entity_component.Phase.PRE_ACT + entity.get_component.side_effect = lambda key, type_=None: component_map[key] + return entity + + def test_selects_component_from_menu_and_returns_pre_act_value(self): + obs_component = mock.MagicMock() + obs_component.get_pre_act_value.return_value = 'observation text' + + selected_component = mock.MagicMock() + selected_component.get_pre_act_value.return_value = 'selected value' + + unselected_component = mock.MagicMock() + unselected_component.get_pre_act_value.return_value = 'other value' + + component = choice_of_component.ChoiceOfComponent( + model=no_language_model.NoLanguageModel(), + observations_component_key='observations', + menu_of_components=('comp_a', 'comp_b'), + ) + entity = self._make_entity({ + 'observations': obs_component, + 'comp_a': unselected_component, + 'comp_b': selected_component, + }) + component.set_entity(entity) + + with mock.patch.object( + choice_of_component.interactive_document, 'InteractiveDocument' + ) as interactive_document_cls: + prompt = interactive_document_cls.return_value + prompt.multiple_choice_question.return_value = 1 + + result = component.get_pre_act_value() + + self.assertEqual(result, 'selected value') + selected_component.get_pre_act_value.assert_called_once() + unselected_component.get_pre_act_value.assert_not_called() + + def test_choice_without_pre_act_suppresses_output_and_delegates_state(self): + obs_component = mock.MagicMock() + obs_component.get_pre_act_value.return_value = 'observation text' + + selected_component = mock.MagicMock() + selected_component.get_pre_act_value.return_value = 'selected value' + + wrapper = choice_of_component.ChoiceOfComponentWithoutPreAct( + model=no_language_model.NoLanguageModel(), + observations_component_key='observations', + menu_of_components=('comp_a',), + ) + + entity = self._make_entity({ + 'observations': obs_component, + 'comp_a': selected_component, + }) + wrapper.set_entity(entity) + + with mock.patch.object( + choice_of_component.interactive_document, 'InteractiveDocument' + ) as interactive_document_cls: + prompt = interactive_document_cls.return_value + prompt.multiple_choice_question.return_value = 0 + + self.assertEqual(wrapper.pre_act(mock.MagicMock()), '') + self.assertEqual(wrapper.get_pre_act_value(), 'selected value') + + +if __name__ == '__main__': + absltest.main() diff --git a/concordia/contrib/components/agent/situation_representation_via_narrative_test.py b/concordia/contrib/components/agent/situation_representation_via_narrative_test.py new file mode 100644 index 000000000..fcc1d7875 --- /dev/null +++ b/concordia/contrib/components/agent/situation_representation_via_narrative_test.py @@ -0,0 +1,148 @@ +"""Tests for situation_representation_via_narrative module.""" + +import datetime +from unittest import mock + +from absl.testing import absltest + +from concordia.contrib.components.agent import situation_representation_via_narrative +from concordia.language_model import no_language_model +from concordia.typing import entity_component + + +class SituationRepresentationTest(absltest.TestCase): + + def _make_entity(self, observation_component, named_components=None): + named_components = named_components or {} + entity = mock.MagicMock() + entity.name = 'Alice' + entity.get_phase.return_value = entity_component.Phase.PRE_ACT + + def get_component(key, type_=None): + del type_ + if key == 'observations': + return observation_component + return named_components[key] + + entity.get_component.side_effect = get_component + return entity + + def test_get_pre_act_value_builds_situation_and_logs(self): + observation_component = mock.MagicMock() + observation_component.get_pre_act_value.return_value = ( + 'Alice arrived at the station.' + ) + + context_component = mock.MagicMock() + context_component.get_pre_act_value.return_value = 'Weather: rainy.' + + logging_channel = mock.MagicMock() + + component = ( + situation_representation_via_narrative.SituationRepresentation( + model=no_language_model.NoLanguageModel(), + observation_component_key='observations', + components=(('weather', 'Weather Context'),), + clock_now=lambda: datetime.datetime(2026, 2, 16, 10, 30), + logging_channel=logging_channel, + ) + ) + component.set_entity( + self._make_entity( + observation_component=observation_component, + named_components={'weather': context_component}, + ) + ) + + with mock.patch.object( + situation_representation_via_narrative.interactive_document, + 'InteractiveDocument', + ) as interactive_document_cls: + first_prompt = mock.MagicMock() + first_prompt.open_question.return_value = 'Initial world summary.' + first_prompt.view.return_value.text.return_value = 'first chain' + + second_prompt = mock.MagicMock() + second_prompt.open_question.return_value = 'Updated situation summary.' + second_prompt.view.return_value.text.return_value = 'second chain' + + interactive_document_cls.side_effect = [first_prompt, second_prompt] + + result = component.get_pre_act_value() + + self.assertEqual(result, 'Updated situation summary.') + self.assertEqual(observation_component.get_pre_act_value.call_count, 2) + context_component.get_pre_act_value.assert_called() + logging_channel.assert_called_once() + logged_payload = logging_channel.call_args[0][0] + self.assertEqual(logged_payload['Value'], 'Updated situation summary.') + self.assertIn('***', logged_payload['Chain of thought']) + + def test_get_pre_act_value_is_cached_until_update(self): + observation_component = mock.MagicMock() + observation_component.get_pre_act_value.return_value = 'obs' + + component = ( + situation_representation_via_narrative.SituationRepresentation( + model=no_language_model.NoLanguageModel(), + observation_component_key='observations', + declare_entity_as_protagonist=False, + ) + ) + component.set_entity(self._make_entity(observation_component)) + + with mock.patch.object( + situation_representation_via_narrative.interactive_document, + 'InteractiveDocument', + ) as interactive_document_cls: + first_prompt = mock.MagicMock() + first_prompt.open_question.return_value = 'Initial summary' + first_prompt.view.return_value.text.return_value = 'initial chain' + + second_prompt = mock.MagicMock() + second_prompt.open_question.return_value = 'Computed summary' + second_prompt.view.return_value.text.return_value = 'update chain' + + interactive_document_cls.side_effect = [ + first_prompt, + second_prompt, + mock.MagicMock( + open_question=mock.MagicMock( + return_value='New summary after update' + ), + view=mock.MagicMock( + return_value=mock.MagicMock( + text=mock.MagicMock(return_value='new chain') + ) + ), + ), + ] + + first_result = component.get_pre_act_value() + second_result = component.get_pre_act_value() + + self.assertEqual(first_result, 'Computed summary') + self.assertEqual(second_result, 'Computed summary') + self.assertEqual(interactive_document_cls.call_count, 2) + + component.update() + third_result = component.get_pre_act_value() + + self.assertEqual(third_result, 'New summary after update') + self.assertEqual(interactive_document_cls.call_count, 3) + + def test_get_and_set_state_round_trip(self): + component = situation_representation_via_narrative.SituationRepresentation( + model=no_language_model.NoLanguageModel(), + observation_component_key='observations', + ) + component.set_state({'situation_thus_far': 'Known context'}) + + self.assertEqual( + component.get_state(), + {'situation_thus_far': 'Known context'}, + ) + + +if __name__ == '__main__': + absltest.main() diff --git a/concordia/contrib/components/conftest.py b/concordia/contrib/components/conftest.py new file mode 100644 index 000000000..4d81d9856 --- /dev/null +++ b/concordia/contrib/components/conftest.py @@ -0,0 +1,7 @@ +"""Shared test configuration for contrib/components tests.""" + +import typing + + +if not hasattr(typing, 'override'): + typing.override = lambda f: f diff --git a/concordia/contrib/components/game_master/day_in_the_life_initializer_test.py b/concordia/contrib/components/game_master/day_in_the_life_initializer_test.py new file mode 100644 index 000000000..ef3893f3d --- /dev/null +++ b/concordia/contrib/components/game_master/day_in_the_life_initializer_test.py @@ -0,0 +1,273 @@ +"""Tests for day_in_the_life_initializer component.""" + +from unittest import mock + +from absl.testing import absltest +from concordia.contrib.components.game_master import day_in_the_life_initializer +from concordia.language_model import no_language_model +from concordia.typing import entity as entity_lib + + +class DayInTheLifeInitializerTest(absltest.TestCase): + + def _make_component(self, **kwargs): + base_kwargs = { + 'model': no_language_model.NoLanguageModel(), + 'next_game_master_name': 'DialogueGM', + 'player_names': ('Alice', 'Bob'), + 'scenario_type': 'first_date', + 'player_specific_memories': {'Alice': ['m1'], 'Bob': ['m2']}, + 'player_specific_context': { + 'Alice': {'wearing': 'blue jacket', 'eating': 'sandwich'}, + 'Bob': {'wearing': 'green shirt', 'eating': 'salad'}, + }, + 'components': (), + 'delimiter_symbol': '***', + 'num_personal_events': 2, + } + base_kwargs.update(kwargs) + return day_in_the_life_initializer.DayInTheLifeInitializer(**base_kwargs) + + def _make_entity(self, memory_component=None, observation_component=None): + memory_component = memory_component or mock.MagicMock() + observation_component = observation_component or mock.MagicMock() + entity = mock.MagicMock() + entity.name = 'InitializerGM' + + def get_component(key, type_=None): + del type_ + if key == '__memory__': + return memory_component + if key == '__make_observation__': + return observation_component + return mock.MagicMock() + + entity.get_component.side_effect = get_component + return entity, memory_component, observation_component + + def test_get_player_background_formats_list_memories(self): + component = self._make_component( + player_specific_memories={'Alice': ['first memory', 'second memory']} + ) + + background = component._get_player_background('Alice') + + self.assertIn('Relevant Memories:', background) + self.assertIn('- first memory', background) + self.assertIn('- second memory', background) + + def test_pre_act_non_next_game_master_is_noop(self): + component = self._make_component() + + result = component.pre_act( + entity_lib.ActionSpec( + call_to_action='ignore', + output_type=entity_lib.OutputType.FREE, + ) + ) + + self.assertEqual(result, '') + + def test_pre_act_initializes_once_then_hands_off(self): + component = self._make_component() + entity, _, _ = self._make_entity() + component.set_entity(entity) + + with mock.patch.object(component, '_process_dyad') as process_dyad: + first = component.pre_act( + entity_lib.ActionSpec( + call_to_action='next', + output_type=entity_lib.OutputType.NEXT_GAME_MASTER, + options=('InitializerGM', 'DialogueGM'), + ) + ) + second = component.pre_act( + entity_lib.ActionSpec( + call_to_action='next', + output_type=entity_lib.OutputType.NEXT_GAME_MASTER, + options=('InitializerGM', 'DialogueGM'), + ) + ) + + self.assertEqual(first, 'InitializerGM') + self.assertEqual(second, 'DialogueGM') + process_dyad.assert_called_once() + + def test_generate_personal_events_splits_by_delimiter(self): + component = self._make_component(num_personal_events=2) + + with mock.patch.object( + day_in_the_life_initializer.interactive_document, + 'InteractiveDocument', + ) as interactive_document_cls: + prompt = interactive_document_cls.return_value + prompt.open_question.return_value = 'Wake up***Have lunch***' + prompt.view.return_value.text.return_value = 'prompt text' + + events = component.generate_personal_events( + player_name='Alice', + context='Today is Monday.', + shared_event='Meet Bob at cafe.', + ) + + self.assertEqual(events, ['Wake up', 'Have lunch']) + + def test_get_and_set_state_round_trip(self): + component = self._make_component() + component.set_state({'initialized': True}) + + self.assertEqual(component.get_state(), {'initialized': True}) + + def test_get_player_background_uses_string_memory(self): + component = self._make_component( + player_specific_memories={'Alice': 'single memory text'} + ) + + background = component._get_player_background('Alice') + + self.assertIn('Relevant Memories:', background) + self.assertIn('single memory text', background) + + def test_generate_shared_setup_unknown_scenario_type_raises(self): + component = self._make_component(scenario_type='unknown_type') + + with self.assertRaisesRegex(ValueError, 'Unknown scenario_type'): + component.generate_shared_setup('Alice', 'Bob', 'context') + + def test_generate_shared_setup_single_rumination_logs_and_returns_scene(self): + component = self._make_component(scenario_type='single_rumination') + logging_channel = mock.MagicMock() + component.set_logging_channel(logging_channel) + + with mock.patch.object( + day_in_the_life_initializer.interactive_document, + 'InteractiveDocument', + ) as interactive_document_cls: + prompt = interactive_document_cls.return_value + prompt.open_question.return_value = 'Alice sits quietly at a cafe.' + prompt.view.return_value.text.return_value = 'prompt trace' + + result = component.generate_shared_setup('Alice', None, 'Evening context') + + self.assertEqual(result, 'Alice sits quietly at a cafe.') + logging_channel.assert_called_once() + logged = logging_channel.call_args[0][0] + self.assertIn('Internal Scene', logged['Key']) + + def test_process_dyad_single_rumination_adds_events_for_both_players(self): + component = self._make_component(scenario_type='single_rumination') + memory = mock.MagicMock() + observation = mock.MagicMock() + + with mock.patch.object( + component, + 'generate_shared_setup', + return_value='internal scene', + ), mock.patch.object( + component, + 'generate_personal_events', + return_value=['event one', 'event two'], + ): + component._process_dyad('context', memory, observation) + + observation.add_to_queue.assert_any_call('Alice', '[Daily Personal Event 1] "event one"') + observation.add_to_queue.assert_any_call('Bob', '[Daily Personal Event 2] "event two"') + observation.add_to_queue.assert_any_call('Alice', '[Internal Scene] "internal scene"') + observation.add_to_queue.assert_any_call('Bob', '[Internal Scene] "internal scene"') + memory.add.assert_any_call('[DITL Internal Scene] Alice: "internal scene"') + + def test_process_dyad_two_player_injects_personal_and_shared_events(self): + component = self._make_component(scenario_type='first_date') + memory = mock.MagicMock() + observation = mock.MagicMock() + + with mock.patch.object( + component, + 'generate_shared_setup', + return_value='shared setup scene', + ), mock.patch.object( + component, + 'generate_personal_events', + side_effect=[['alice event 1', 'alice event 2'], ['bob event 1']], + ): + component._process_dyad('context', memory, observation) + + observation.add_to_queue.assert_any_call( + 'Alice', '[Daily Personal Event 1] "alice event 1"' + ) + observation.add_to_queue.assert_any_call( + 'Alice', '[Daily Personal Event 2] "alice event 2"' + ) + observation.add_to_queue.assert_any_call( + 'Bob', '[Daily Personal Event 1] "bob event 1"' + ) + observation.add_to_queue.assert_any_call( + 'Alice', '[Daily Shared Setup] "shared setup scene"' + ) + observation.add_to_queue.assert_any_call( + 'Bob', '[Daily Shared Setup] "shared setup scene"' + ) + memory.add.assert_any_call('[DITL Personal Event] Alice: "alice event 1"') + memory.add.assert_any_call('[DITL Personal Event] Bob: "bob event 1"') + memory.add.assert_any_call( + '[DITL Shared Setup] Alice and Bob: "shared setup scene"' + ) + + def test_process_dyad_two_player_handles_empty_personal_events(self): + component = self._make_component(scenario_type='friend_meetup') + memory = mock.MagicMock() + observation = mock.MagicMock() + + with mock.patch.object( + component, + 'generate_shared_setup', + return_value='shared meetup scene', + ), mock.patch.object( + component, + 'generate_personal_events', + side_effect=[[], []], + ): + component._process_dyad('context', memory, observation) + + self.assertEqual(observation.add_to_queue.call_count, 2) + observation.add_to_queue.assert_any_call( + 'Alice', '[Daily Shared Setup] "shared meetup scene"' + ) + observation.add_to_queue.assert_any_call( + 'Bob', '[Daily Shared Setup] "shared meetup scene"' + ) + memory.add.assert_called_once_with( + '[DITL Shared Setup] Alice and Bob: "shared meetup scene"' + ) + + def test_process_dyad_two_player_invalid_participant_propagates_error(self): + component = self._make_component(player_names=('Alice', 'Eve')) + memory = mock.MagicMock() + observation = mock.MagicMock() + + with mock.patch.object( + component, + 'generate_shared_setup', + return_value='shared setup scene', + ), mock.patch.object( + component, + 'generate_personal_events', + side_effect=[['alice event'], KeyError('Eve')], + ): + with self.assertRaises(KeyError): + component._process_dyad('context', memory, observation) + + def test_generate_personal_events_asserts_when_eating_statement_empty(self): + component = self._make_component( + player_specific_context={ + 'Alice': {'wearing': 'blue jacket', 'eating': ' '}, + 'Bob': {'wearing': 'green shirt', 'eating': 'salad'}, + } + ) + + with self.assertRaisesRegex(AssertionError, 'eating statement is empty'): + component.generate_personal_events('Alice', 'context', 'shared') + + +if __name__ == '__main__': + absltest.main() diff --git a/concordia/contrib/components/game_master/death_test.py b/concordia/contrib/components/game_master/death_test.py new file mode 100644 index 000000000..1a3f106d3 --- /dev/null +++ b/concordia/contrib/components/game_master/death_test.py @@ -0,0 +1,204 @@ +"""Tests for death component.""" + +from unittest import mock + +from absl.testing import absltest +from concordia.contrib.components.game_master import death +from concordia.language_model import no_language_model +from concordia.typing import entity as entity_lib + + +class DeathTest(absltest.TestCase): + """Tests death resolution and non-resolution behavior.""" + + def _make_component(self): + return death.Death( + model=no_language_model.NoLanguageModel(), + pre_act_label='Death', + actor_names=['Alice', 'Bob'], + ) + + def _make_entity( + self, + next_acting_component, + observation_component, + memory, + terminator=None, + ): + entity = mock.MagicMock() + terminator = terminator or mock.MagicMock() + + def get_component(key, type_=None): + del type_ + if key == '__next_acting__': + return next_acting_component + if key == '__make_observation__': + return observation_component + if key == '__memory__': + return memory + if key == '__terminate__': + return terminator + return mock.MagicMock() + + entity.get_component.side_effect = get_component + return entity + + def test_post_act_non_resolve_is_noop(self): + component = self._make_component() + component.pre_act( + entity_lib.ActionSpec( + call_to_action='free action', + output_type=entity_lib.OutputType.FREE, + ) + ) + + with mock.patch.object( + death.interactive_document, 'InteractiveDocument' + ) as interactive_document_cls: + result = component.post_act('An event happened.') + + self.assertEqual(result, '') + self.assertFalse(interactive_document_cls.called) + + def test_post_act_resolve_kills_actor_and_updates_components(self): + component = self._make_component() + + next_acting_component = mock.MagicMock() + observation_component = mock.MagicMock() + memory = mock.MagicMock() + entity = self._make_entity( + next_acting_component=next_acting_component, + observation_component=observation_component, + memory=memory, + ) + component.set_entity(entity) + + component.pre_act( + entity_lib.ActionSpec( + call_to_action='resolve', + output_type=entity_lib.OutputType.RESOLVE, + ) + ) + + with mock.patch.object( + death.interactive_document, 'InteractiveDocument' + ) as interactive_document_cls: + prompt = interactive_document_cls.return_value + prompt.yes_no_question.return_value = True + prompt.open_question.return_value = 'Alice' + + result = component.post_act('A dangerous event happened.') + + self.assertEqual(result, '') + next_acting_component.remove_actor_from_sequence.assert_called_once_with( + 'Alice' + ) + observation_component.add_to_queue.assert_called_once_with( + 'Alice', 'Alice has died.' + ) + memory.add.assert_called_once_with('Alice has died.') + self.assertEqual(component.get_state()['actors_names'], ['Bob']) + + def test_post_act_resolve_no_death_keyword_keeps_state(self): + component = self._make_component() + next_acting_component = mock.MagicMock() + observation_component = mock.MagicMock() + memory = mock.MagicMock() + component.set_entity( + self._make_entity(next_acting_component, observation_component, memory) + ) + + component.pre_act( + entity_lib.ActionSpec( + call_to_action='resolve', + output_type=entity_lib.OutputType.RESOLVE, + ) + ) + + with mock.patch.object( + death.interactive_document, 'InteractiveDocument' + ) as interactive_document_cls: + prompt = interactive_document_cls.return_value + prompt.yes_no_question.return_value = True + prompt.open_question.return_value = 'NO_DEATH' + + result = component.post_act('A risky event happened.') + + self.assertEqual(result, '') + next_acting_component.remove_actor_from_sequence.assert_not_called() + observation_component.add_to_queue.assert_not_called() + memory.add.assert_not_called() + self.assertEqual(component.get_state()['actors_names'], ['Alice', 'Bob']) + + def test_post_act_ignores_unknown_names_in_who_died(self): + component = self._make_component() + next_acting_component = mock.MagicMock() + observation_component = mock.MagicMock() + memory = mock.MagicMock() + component.set_entity( + self._make_entity(next_acting_component, observation_component, memory) + ) + + component.pre_act( + entity_lib.ActionSpec( + call_to_action='resolve', + output_type=entity_lib.OutputType.RESOLVE, + ) + ) + + with mock.patch.object( + death.interactive_document, 'InteractiveDocument' + ) as interactive_document_cls: + prompt = interactive_document_cls.return_value + prompt.yes_no_question.return_value = True + prompt.open_question.return_value = 'Charlie, Alice.' + + component.post_act('A dangerous event happened.') + + next_acting_component.remove_actor_from_sequence.assert_called_once_with( + 'Alice' + ) + self.assertEqual(component.get_state()['actors_names'], ['Bob']) + + def test_post_act_no_death_and_no_actors_terminates(self): + component = self._make_component() + component.set_state( + { + 'actors_names': [], + 'step_counter': 0, + 'last_action_spec': None, + } + ) + next_acting_component = mock.MagicMock() + observation_component = mock.MagicMock() + memory = mock.MagicMock() + terminator = mock.MagicMock() + component.set_entity( + self._make_entity( + next_acting_component, + observation_component, + memory, + terminator=terminator, + ) + ) + + component.pre_act( + entity_lib.ActionSpec( + call_to_action='resolve', + output_type=entity_lib.OutputType.RESOLVE, + ) + ) + + with mock.patch.object( + death.interactive_document, 'InteractiveDocument' + ) as interactive_document_cls: + prompt = interactive_document_cls.return_value + prompt.yes_no_question.return_value = False + + component.post_act('No one died this turn.') + + terminator.terminate.assert_called_once() + + +if __name__ == '__main__': + absltest.main() diff --git a/concordia/contrib/components/game_master/marketplace_test.py b/concordia/contrib/components/game_master/marketplace_test.py new file mode 100644 index 000000000..910401c92 --- /dev/null +++ b/concordia/contrib/components/game_master/marketplace_test.py @@ -0,0 +1,363 @@ +"""Tests for marketplace component.""" + +from unittest import mock + +from absl.testing import absltest +from concordia.contrib.components.game_master import marketplace +from concordia.typing import entity as entity_lib + + +class MarketPlaceTest(absltest.TestCase): + + def _make_component(self): + agents = [ + marketplace.MarketplaceAgent( + name='Alice', + role='producer', + cash=100.0, + inventory={'apple': 3}, + queue=[], + ), + marketplace.MarketplaceAgent( + name='Alice Longname', + role='consumer', + cash=120.0, + inventory={}, + queue=[], + ), + ] + goods = [ + marketplace.Good( + category='food', quality='fresh', id='apple', price=5.0, inventory=10 + ) + ] + component = marketplace.MarketPlace( + acting_player_names=['Alice', 'Alice Longname'], + agents=agents, + goods=goods, + market_type='fixed_prices', + show_advert=False, + ) + entity = mock.MagicMock() + entity.name = 'MarketplaceGM' + component.set_entity(entity) + return component + + def test_pre_act_make_observation_prefers_longest_matching_name(self): + component = self._make_component() + + with mock.patch.object( + component, + '_handle_make_observation', + return_value='observation text', + ) as handle_make_observation: + result = component.pre_act( + entity_lib.ActionSpec( + call_to_action='What does Alice Longname observe now?', + output_type=entity_lib.OutputType.MAKE_OBSERVATION, + ) + ) + + self.assertEqual(result, 'observation text') + handle_make_observation.assert_called_once_with('Alice Longname') + + def test_pre_act_raises_when_agent_name_missing(self): + component = self._make_component() + + with self.assertRaisesRegex(ValueError, 'Agent name not found'): + component.pre_act( + entity_lib.ActionSpec( + call_to_action='No listed agent appears here.', + output_type=entity_lib.OutputType.NEXT_ACTION_SPEC, + ) + ) + + def test_get_and_set_state_round_trip_with_orderbooks(self): + component = self._make_component() + + good = component._goods['apple'] + component._orderbooks['apple'].append( + marketplace.Order( + agent_id='Alice Longname', + good=good, + price=6.0, + qty=2, + side='bid', + round=1, + ) + ) + component.history.append({'apple': 5.5}) + component.trade_history.append({'round': 1, 'good': 'apple'}) + component.curve_history = {1: {'apple': {'supply': [1], 'demand': [1]}}} + component._processed_actions = {'event-a'} + + state = component.get_state() + + restored = self._make_component() + restored.set_state(state) + + restored_state = restored.get_state() + self.assertEqual(restored_state['state'], state['state']) + self.assertEqual(restored_state['history'], state['history']) + self.assertEqual(restored_state['trade_history'], state['trade_history']) + self.assertEqual(restored_state['processed_actions'], ['event-a']) + self.assertLen(restored._orderbooks['apple'], 1) + self.assertIsInstance(restored._orderbooks['apple'][0].good, marketplace.Good) + self.assertEqual(restored._orderbooks['apple'][0].good.id, 'apple') + + def test_handle_make_observation_fixed_prices_with_advert_and_queue(self): + agents = [ + marketplace.MarketplaceAgent( + name='Alice', + role='consumer', + cash=50.0, + inventory={}, + queue=['Bought 1 apple yesterday.'], + ) + ] + goods = [ + marketplace.Good( + category='food', + quality='fresh', + id='apple', + price=5.0, + inventory=4, + advert='Crisp and local.', + ) + ] + component = marketplace.MarketPlace( + acting_player_names=['Alice'], + agents=agents, + goods=goods, + market_type='fixed_prices', + show_advert=True, + ) + entity = mock.MagicMock() + entity.name = 'MarketplaceGM' + component.set_entity(entity) + + result = component._handle_make_observation('Alice') + + self.assertIn("Alice's recent outcomes", result) + self.assertIn('Crisp and local.', result) + self.assertIn('Submit your order.', result) + self.assertEmpty(component._agents['Alice'].queue) + + def test_handle_make_observation_unknown_market_type_raises(self): + component = self._make_component() + component._market_type = 'unknown_market' + + with self.assertRaisesRegex(ValueError, 'Unknown market type'): + component._handle_make_observation('Alice') + + def test_handle_next_action_spec_unknown_role_returns_error_text(self): + component = self._make_component() + component._agents['Alice'].role = 'mystery' + + action_spec_string = component._handle_next_action_spec('Alice') + + self.assertIn('Error: Agent has unknown role.', action_spec_string) + + def test_handle_next_acting_cycles_through_players(self): + component = self._make_component() + + first = component._handle_next_acting() + second = component._handle_next_acting() + third = component._handle_next_acting() + + self.assertEqual(first, 'Alice') + self.assertEqual(second, 'Alice Longname') + self.assertEqual(third, 'Alice') + + def test_resolve_without_putative_event_returns_error(self): + component = self._make_component() + component._components = ('memory',) + + with mock.patch.object( + component, + '_component_pre_act_display', + return_value='[observation] plain text without event', + ): + result = component._resolve( + entity_lib.ActionSpec( + call_to_action='resolve', + output_type=entity_lib.OutputType.RESOLVE, + ) + ) + + self.assertEqual(result, 'Error: No putative event found to resolve.') + + def test_resolve_uses_last_price_when_trade_price_is_nan(self): + component = self._make_component() + component._components = ('memory',) + component.history = [{'apple': 9.5}] + + event_text = ( + '[observation] [putative_event] ' + 'Alice says {"action":"ask","good":"apple","price":7,"qty":1} ' + 'Alice Longname says ' + '{"action":"bid","good":"apple","price":8,"qty":1}' + ) + + with mock.patch.object( + component, + '_component_pre_act_display', + return_value=event_text, + ), mock.patch.object( + component, + '_clear_at_fixed_prices', + return_value=(float('nan'), []), + ): + result = component._resolve( + entity_lib.ActionSpec( + call_to_action='resolve', + output_type=entity_lib.OutputType.RESOLVE, + ) + ) + + self.assertIn('No sales were made.', result) + self.assertIn("Day 0 prices: {'apple': 9.5}", result) + self.assertEqual(component._state['round'], 1) + self.assertEmpty(component._orderbooks['apple']) + + def test_clear_at_fixed_prices_raises_for_missing_price(self): + component = self._make_component() + component._goods['apple'].price = None + + with self.assertRaisesRegex(ValueError, 'has no price'): + component._clear_at_fixed_prices('apple') + + def test_clear_at_fixed_prices_inventory_none_returns_nan(self): + component = self._make_component() + component._goods['apple'].inventory = None + + price, completed = component._clear_at_fixed_prices('apple') + + self.assertTrue(price != price) + self.assertEqual(completed, []) + + def test_clear_at_fixed_prices_insufficient_cash_adds_failure_message(self): + component = self._make_component() + component._agents['Alice Longname'].cash = 1.0 + component._orderbooks['apple'].append( + marketplace.Order( + agent_id='Alice Longname', + good=component._goods['apple'], + price=5.0, + qty=2, + side='bid', + round=0, + ) + ) + + with mock.patch.object( + marketplace.random, 'shuffle', side_effect=lambda x: x + ): + price, completed = component._clear_at_fixed_prices('apple') + + self.assertEqual(price, 5.0) + self.assertEqual(completed, []) + self.assertIn( + 'do not have enough cash', + component._agents['Alice Longname'].queue[0], + ) + + def test_clear_auction_without_counterparty_returns_lowest_ask(self): + component = self._make_component() + component._market_type = 'clearing_house' + component._orderbooks['apple'].append( + marketplace.Order( + agent_id='Alice', + good=component._goods['apple'], + price=6.0, + qty=1, + side='ask', + round=0, + ) + ) + + trade_price, completed, traded = component._clear_auction('apple') + + self.assertEqual(trade_price, 6.0) + self.assertEqual(completed, []) + self.assertFalse(traded) + self.assertIn( + 'did not result in a trade as there were no counterparties', + component._agents['Alice'].queue[0], + ) + + def test_clear_auction_executes_trade_and_updates_balances(self): + component = self._make_component() + component._market_type = 'clearing_house' + component._agents['Alice'].cash = 0.0 + component._agents['Alice'].inventory = {'apple': 2} + component._agents['Alice Longname'].cash = 50.0 + + component._orderbooks['apple'].extend([ + marketplace.Order( + agent_id='Alice', + good=component._goods['apple'], + price=6.0, + qty=2, + side='ask', + round=0, + ), + marketplace.Order( + agent_id='Alice Longname', + good=component._goods['apple'], + price=10.0, + qty=2, + side='bid', + round=0, + ), + ]) + + trade_price, completed, traded = component._clear_auction('apple') + + self.assertEqual(trade_price, 8.0) + self.assertTrue(traded) + self.assertLen(completed, 1) + self.assertEqual(component._agents['Alice'].cash, 16.0) + self.assertEqual(component._agents['Alice'].inventory['apple'], 0) + self.assertEqual(component._agents['Alice Longname'].cash, 34.0) + self.assertEqual(component._agents['Alice Longname'].inventory['apple'], 2) + + def test_clear_auction_insufficient_cash_branch(self): + component = self._make_component() + component._market_type = 'clearing_house' + component._agents['Alice'].cash = 0.0 + component._agents['Alice'].inventory = {'apple': 2} + component._agents['Alice Longname'].cash = 10.0 + + component._orderbooks['apple'].extend([ + marketplace.Order( + agent_id='Alice', + good=component._goods['apple'], + price=6.0, + qty=2, + side='ask', + round=0, + ), + marketplace.Order( + agent_id='Alice Longname', + good=component._goods['apple'], + price=10.0, + qty=2, + side='bid', + round=0, + ), + ]) + + trade_price, completed, traded = component._clear_auction('apple') + + self.assertEqual(trade_price, 8.0) + self.assertEqual(completed, []) + self.assertTrue(traded) + self.assertIn( + 'do not have enough cash', + component._agents['Alice Longname'].queue[0], + ) + + +if __name__ == '__main__': + absltest.main() diff --git a/concordia/contrib/components/game_master/spaceship_system_test.py b/concordia/contrib/components/game_master/spaceship_system_test.py new file mode 100644 index 000000000..6c6926ef0 --- /dev/null +++ b/concordia/contrib/components/game_master/spaceship_system_test.py @@ -0,0 +1,147 @@ +"""Tests for spaceship_system module.""" + +from unittest import mock + +from absl.testing import absltest + +from concordia.contrib.components.game_master import spaceship_system +from concordia.language_model import no_language_model +from concordia.typing import entity as entity_lib + + +class SpaceshipSystemTest(absltest.TestCase): + + def _make_component(self): + return spaceship_system.SpaceshipSystem( + model=no_language_model.NoLanguageModel(), + system_name='Engine Core', + system_max_health=3, + system_failure_probability=1.0, + warning_message='Warning: Engine Core is failing.', + pre_act_label='Spaceship System', + ) + + def _make_entity(self, memory, observation, terminator, extra_components=None): + entity = mock.MagicMock() + extra_components = extra_components or {} + + def get_component(key, type_=None): + del type_ + if key == '__memory__': + return memory + if key == '__make_observation__': + return observation + if key == '__terminate__': + return terminator + return extra_components[key] + + entity.get_component.side_effect = get_component + return entity + + def test_pre_act_resolve_triggers_failure_and_decrements_health(self): + component = self._make_component() + memory = mock.MagicMock() + observation = mock.MagicMock() + terminator = mock.MagicMock() + component.set_entity(self._make_entity(memory, observation, terminator)) + + with mock.patch.object( + spaceship_system.random, 'random', return_value=0.0 + ): + result = component.pre_act( + entity_lib.ActionSpec( + call_to_action='resolve', + output_type=entity_lib.OutputType.RESOLVE, + ) + ) + + self.assertIn('System name: Engine Core', result) + observation.add_to_queue.assert_called_once_with( + 'All', 'Warning: Engine Core is failing.' + ) + memory.add.assert_called_once_with('Warning: Engine Core is failing.') + self.assertEqual(component.get_state()['current_health'], 2) + self.assertEqual(component.get_state()['step_counter'], 1) + + def test_pre_act_non_resolve_is_noop(self): + component = self._make_component() + memory = mock.MagicMock() + observation = mock.MagicMock() + terminator = mock.MagicMock() + component.set_entity(self._make_entity(memory, observation, terminator)) + + result = component.pre_act( + entity_lib.ActionSpec( + call_to_action='free action', + output_type=entity_lib.OutputType.FREE, + ) + ) + + self.assertEqual(result, '') + observation.add_to_queue.assert_not_called() + memory.add.assert_not_called() + + def test_post_act_when_fixed_resets_health_and_notifies(self): + component = self._make_component() + component.set_state({ + 'system_name': 'Engine Core', + 'system_max_health': 3, + 'system_failure_probability': 1.0, + 'current_health': 1, + 'is_failing': True, + 'verbose': False, + 'step_counter': 2, + }) + + memory = mock.MagicMock() + observation = mock.MagicMock() + terminator = mock.MagicMock() + component.set_entity(self._make_entity(memory, observation, terminator)) + + with mock.patch.object( + spaceship_system.interactive_document, 'InteractiveDocument' + ) as interactive_document_cls: + prompt = interactive_document_cls.return_value + prompt.yes_no_question.return_value = True + + result = component.post_act('Crew repaired the subsystem.') + + self.assertEqual(result, '') + observation.add_to_queue.assert_called_once_with( + 'All', 'The Engine Core was fixed.' + ) + memory.add.assert_called_once_with('The Engine Core was fixed.') + self.assertEqual(component.get_state()['current_health'], 3) + self.assertFalse(component.get_state()['is_failing']) + terminator.terminate.assert_not_called() + + def test_post_act_unfixed_and_zero_health_terminates(self): + component = self._make_component() + component.set_state({ + 'system_name': 'Engine Core', + 'system_max_health': 3, + 'system_failure_probability': 1.0, + 'current_health': 0, + 'is_failing': True, + 'verbose': False, + 'step_counter': 2, + }) + + memory = mock.MagicMock() + observation = mock.MagicMock() + terminator = mock.MagicMock() + component.set_entity(self._make_entity(memory, observation, terminator)) + + with mock.patch.object( + spaceship_system.interactive_document, 'InteractiveDocument' + ) as interactive_document_cls: + prompt = interactive_document_cls.return_value + prompt.yes_no_question.return_value = False + + component.post_act('Crew failed to repair the subsystem.') + + terminator.terminate.assert_called_once() + + +if __name__ == '__main__': + absltest.main() diff --git a/concordia/language_model/markdown_stripper.py b/concordia/language_model/markdown_stripper.py new file mode 100644 index 000000000..bbd472cbf --- /dev/null +++ b/concordia/language_model/markdown_stripper.py @@ -0,0 +1,44 @@ +# 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. + +"""Markdown stripping utilities for language model responses.""" + +import re + + +def strip_markdown(text: str) -> str: + """Strips markdown code blocks and formatting from text. + + This function removes: + - Triple backtick code blocks (```...```) + - Single backtick inline code (`...`) + - Leading and trailing whitespace + + Args: + text: The text to strip markdown from. + + Returns: + The text with markdown formatting removed. + """ + result = text + + result = re.sub(r'```[\w]*\n', '\n', result) + result = re.sub(r'```$', '', result, flags=re.MULTILINE) + result = re.sub(r'\n\n+', '\n', result) + + result = re.sub(r'`([^`]+)`', r'\1', result) + + result = result.strip() + + return result \ No newline at end of file diff --git a/concordia/language_model/markdown_stripper_test.py b/concordia/language_model/markdown_stripper_test.py new file mode 100644 index 000000000..dd9ce05cf --- /dev/null +++ b/concordia/language_model/markdown_stripper_test.py @@ -0,0 +1,55 @@ +# 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. + +"""Tests for markdown stripping utilities.""" + +from concordia.language_model import markdown_stripper +from absl.testing import absltest + + +class MarkdownStripperTest(absltest.TestCase): + + def test_strip_triple_backticks(self): + text = '```json\n{"key": "value"}\n```' + result = markdown_stripper.strip_markdown(text) + self.assertEqual(result, '{"key": "value"}') + + def test_strip_triple_backticks_no_language(self): + text = '```\nsome code\n```' + result = markdown_stripper.strip_markdown(text) + self.assertEqual(result, 'some code') + + def test_strip_inline_code(self): + text = 'Use the `print()` function.' + result = markdown_stripper.strip_markdown(text) + self.assertEqual(result, 'Use the print() function.') + + def test_strip_leading_trailing_whitespace(self): + text = ' some text ' + result = markdown_stripper.strip_markdown(text) + self.assertEqual(result, 'some text') + + def test_no_markdown(self): + text = 'Just plain text.' + result = markdown_stripper.strip_markdown(text) + self.assertEqual(result, 'Just plain text.') + + def test_mixed_markdown(self): + text = '```json\n{"name": "test"}\n```\nUse `print()` here. ' + result = markdown_stripper.strip_markdown(text) + self.assertEqual(result, '{"name": "test"}\nUse print() here.') + + +if __name__ == '__main__': + absltest.main() \ No newline at end of file diff --git a/concordia/language_model/resilient_model.py b/concordia/language_model/resilient_model.py new file mode 100644 index 000000000..0935a21ff --- /dev/null +++ b/concordia/language_model/resilient_model.py @@ -0,0 +1,104 @@ +# 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. + +"""Resilient language model wrapper with retry and markdown stripping.""" + +from collections.abc import Collection, Mapping, Sequence +from typing import Any, Type, override + +from concordia.language_model import language_model +from concordia.language_model import markdown_stripper +from concordia.language_model import retry_wrapper + + +class ResilientLanguageModel(retry_wrapper.RetryLanguageModel): + """Wraps a language model with retry and markdown stripping.""" + + def __init__( + self, + model: language_model.LanguageModel, + *, + strip_markdown: bool = True, + retry_on_exceptions: Collection[Type[Exception]] = (Exception,), + retry_tries: int = 3, + retry_delay: float = 2.0, + jitter: tuple[float, float] = (0.0, 1.0), + exponential_backoff: bool = True, + backoff_factor: float = 2.0, + max_delay: float = 300.0, + ) -> None: + """Wrap the underlying language model with retries and markdown stripping. + + Args: + model: A language model to wrap. + strip_markdown: Whether to strip markdown formatting from responses. + retry_on_exceptions: The exception types to retry on. + retry_tries: Number of retries before failing. + retry_delay: Minimum delay between retries. + jitter: Tuple of minimum and maximum jitter to add to the retry. + exponential_backoff: Whether to enable exponential backoff. + backoff_factor: The factor to use for exponential backoff. + max_delay: The maximum delay between retries. + """ + super().__init__( + model=model, + retry_on_exceptions=retry_on_exceptions, + retry_tries=retry_tries, + retry_delay=retry_delay, + jitter=jitter, + exponential_backoff=exponential_backoff, + backoff_factor=backoff_factor, + max_delay=max_delay, + ) + self._strip_markdown = strip_markdown + + @override + def sample_text( + self, + prompt: str, + *, + max_tokens: int = language_model.DEFAULT_MAX_TOKENS, + terminators: Collection[str] = language_model.DEFAULT_TERMINATORS, + temperature: float = language_model.DEFAULT_TEMPERATURE, + top_p: float = language_model.DEFAULT_TOP_P, + top_k: int = language_model.DEFAULT_TOP_K, + timeout: float = language_model.DEFAULT_TIMEOUT_SECONDS, + seed: int | None = None, + ) -> str: + result = super().sample_text( + prompt, + max_tokens=max_tokens, + terminators=terminators, + temperature=temperature, + top_p=top_p, + top_k=top_k, + timeout=timeout, + seed=seed, + ) + if self._strip_markdown: + result = markdown_stripper.strip_markdown(result) + return result + + @override + def sample_choice( + self, + prompt: str, + responses: Sequence[str], + *, + seed: int | None = None, + ) -> tuple[int, str, Mapping[str, Any]]: + index, result, info = super().sample_choice(prompt, responses, seed=seed) + if self._strip_markdown: + result = markdown_stripper.strip_markdown(result) + return index, result, info \ No newline at end of file diff --git a/examples/games/haggling/haggling.py b/examples/games/haggling/haggling.py index df7844257..a16ad16cd 100644 --- a/examples/games/haggling/haggling.py +++ b/examples/games/haggling/haggling.py @@ -17,7 +17,7 @@ import argparse import importlib -from concordia.contrib.language_models import language_model_setup as language_model_utils +from concordia.contrib import language_models as language_model_utils from examples.games.haggling import simulation import sentence_transformers diff --git a/examples/games/haggling_multi_item/haggling_multi_item.py b/examples/games/haggling_multi_item/haggling_multi_item.py index 37f45a63b..134b6d2f1 100644 --- a/examples/games/haggling_multi_item/haggling_multi_item.py +++ b/examples/games/haggling_multi_item/haggling_multi_item.py @@ -17,7 +17,7 @@ import argparse import importlib -from concordia.contrib.language_models import language_model_setup +from concordia.contrib import language_models from examples.games.haggling_multi_item import simulation import sentence_transformers @@ -69,7 +69,7 @@ def main() -> None: ) from e # Set up the language model - model = language_model_setup.language_model_setup( + model = language_models.language_model_setup( api_type=args.api_type, model_name=args.model_name, api_key=args.api_key, diff --git a/examples/games/pub_coordination/pub_coordination.py b/examples/games/pub_coordination/pub_coordination.py index 5e5a7f137..e731b67ae 100644 --- a/examples/games/pub_coordination/pub_coordination.py +++ b/examples/games/pub_coordination/pub_coordination.py @@ -17,7 +17,7 @@ import argparse import importlib -from concordia.contrib.language_models import language_model_setup +from concordia.contrib import language_models from examples.games.pub_coordination import simulation import sentence_transformers @@ -61,7 +61,7 @@ def main() -> None: args = parser.parse_args() - model = language_model_setup.language_model_setup( + model = language_models.language_model_setup( api_type=args.api_type, model_name=args.model_name, api_key=args.api_key,