Skip to content

Commit fc1d1dd

Browse files
committed
feat: change engine to use new multi-turn conversation format
1 parent 7c0ef8a commit fc1d1dd

9 files changed

Lines changed: 143 additions & 128 deletions

File tree

scripts/create_sft_dataset.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,6 @@
1919
from transformers import AutoTokenizer
2020

2121
from among_them.game_engine import GameEngine
22-
from among_them.models.phase import GamePhase
23-
from among_them.utils.prompt_utils import reconstruct_environment_prompt_from_history
24-
from among_them.utils.phase_utils import count_votes
2522

2623
# Used as fallback to calculate token counts if actual tokenizer is not available
2724
CHARS_PER_TOKEN = 4

scripts/extract_benchmark_examples_llm.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from among_them.game_jsonencoder import game_object_hook
2525
from among_them.models.history import History
2626
from among_them.models.action_type import ActionType
27-
from among_them.utils.prompt_utils import reconstruct_environment_prompt_from_history
27+
from among_them.utils.prompt_utils import build_conversation_for_player
2828
from among_them.utils.player_utils import get_last_player_action, get_players_in_room, get_dead_players
2929

3030

@@ -70,11 +70,13 @@ def analyze_action_with_llm(
7070
if history_entry.phase.name == "DISCUSS":
7171
return None
7272

73-
# Reconstruct the full prompt
73+
# Build conversation up to this point
7474
try:
75-
full_prompt = reconstruct_environment_prompt_from_history(current_player, history, players, game_config)
75+
conversation = build_conversation_for_player(current_player, history, players, game_config)
76+
# Get last user message as the "full prompt" for analysis
77+
full_prompt = conversation[-1]["content"] if conversation and conversation[-1]["role"] == "user" else ""
7678
except Exception as e:
77-
print(f"Error reconstructing prompt for {player_name}: {e}")
79+
print(f"Error building conversation for {player_name}: {e}")
7880
return None
7981

8082
# Get LLM response and chain of thought
@@ -296,14 +298,16 @@ def extract_scenario_data(
296298
llm_cot = getattr(history_entry, "llm_cot", "")
297299
llm_response = getattr(history_entry, "llm_response", "")
298300

299-
# Reconstruct the full prompt
301+
# Build conversation up to this point
300302
try:
301303
current_player = next((p for p in players if p.name == player_name), None)
302304
if not current_player:
303305
return None
304-
full_prompt = reconstruct_environment_prompt_from_history(current_player, history, players, game_config)
306+
conversation = build_conversation_for_player(current_player, history, players, game_config)
307+
# Get last user message as the "full prompt" for analysis
308+
full_prompt = conversation[-1]["content"] if conversation and conversation[-1]["role"] == "user" else ""
305309
except Exception as e:
306-
print(f"Error reconstructing prompt for {player_name}: {e}")
310+
print(f"Error building conversation for {player_name}: {e}")
307311
return None
308312

309313
return {
@@ -420,12 +424,9 @@ def _norm(s: str) -> str:
420424
margin_ok = (chosen_baseline - baseline_best) >= 0.15
421425

422426
# CoT quality judge
423-
full_prompt = reconstruct_environment_prompt_from_history(
424-
next((p for p in players if p.name == history_entry.action_taken.player_name), players[0]),
425-
history_slice,
426-
players,
427-
game_config,
428-
)
427+
current_player_cot = next((p for p in players if p.name == history_entry.action_taken.player_name), players[0])
428+
conversation = build_conversation_for_player(current_player_cot, history_slice, players, game_config)
429+
full_prompt = conversation[-1]["content"] if conversation and conversation[-1]["role"] == "user" else ""
429430
cot_quality = analyze_cot_quality_with_llm(
430431
history_entry,
431432
history_entry.action_taken.player_name,

scripts/manual_llm_game.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def main(reset=False, remove_last_n=0, game_config: GameConfig = None, state_fil
4545
print(history_item)
4646

4747
while True:
48-
turn_context_history, actions_player_can_take, system_prompt, user_prompt, pre_discussion_vote_prompts = engine.get_turn_context()
48+
turn_context_history, actions_player_can_take, conversation, pre_discussion_vote_prompts = engine.get_turn_context()
4949
if not turn_context_history:
5050
print("Game over!")
5151
break
@@ -55,8 +55,7 @@ def main(reset=False, remove_last_n=0, game_config: GameConfig = None, state_fil
5555
print("--- Collecting Pre-Discussion Votes ---")
5656
for vote_prompt in pre_discussion_vote_prompts:
5757
player = vote_prompt["player"]
58-
system_prompt_pd = vote_prompt["system_prompt"]
59-
user_prompt_pd = vote_prompt["user_prompt"]
58+
pd_conversation = vote_prompt["conversation"]
6059
actions_pd = vote_prompt["actions"]
6160

6261
# Retry loop for pre-discussion votes
@@ -68,8 +67,7 @@ def main(reset=False, remove_last_n=0, game_config: GameConfig = None, state_fil
6867
# Build allowed actions for early-stop single-line streaming
6968
allowed_actions_pd = [a.set_stories().command_perspective for a in actions_pd]
7069
llm_response, cot = invoke_llm(
71-
system_prompt_pd,
72-
user_prompt_pd,
70+
pd_conversation,
7371
player.llm_model_name,
7472
allowed_actions=allowed_actions_pd,
7573
single_line_only=True,
@@ -132,8 +130,7 @@ def main(reset=False, remove_last_n=0, game_config: GameConfig = None, state_fil
132130
single_line_only = len(allowed_actions_main) > 0
133131
allowed_actions_for_call = allowed_actions_main if single_line_only else None
134132
llm_response, cot = invoke_llm(
135-
system_prompt,
136-
user_prompt,
133+
conversation,
137134
current_player.llm_model_name,
138135
allowed_actions=allowed_actions_for_call,
139136
single_line_only=single_line_only,
@@ -182,7 +179,9 @@ def main(reset=False, remove_last_n=0, game_config: GameConfig = None, state_fil
182179

183180
# Calculate token usage with tiktoken
184181
encoding = tiktoken.encoding_for_model("gpt-4o")
185-
input_tokens = len(encoding.encode(system_prompt + user_prompt))
182+
# Count tokens for all messages in conversation
183+
conversation_text = "\n".join([msg["content"] for msg in conversation])
184+
input_tokens = len(encoding.encode(conversation_text))
186185
output_tokens = len(encoding.encode(response_text + (cot or "")))
187186
token_usage = {"input_tokens": input_tokens, "output_tokens": output_tokens}
188187

scripts/random_game.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def main(reset=False, greedy=False, remove_last_n=0):
4141
print(history_item)
4242

4343
while True:
44-
turn_context_history, actions_player_can_take, system_prompt, user_prompt, pre_discussion_vote_prompts = engine.get_turn_context()
44+
turn_context_history, actions_player_can_take, conversation, pre_discussion_vote_prompts = engine.get_turn_context()
4545
if not turn_context_history:
4646
print("Game over!")
4747
break

scripts/replay_games.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def replay_game(json_file: Path, output_folder: Path):
106106
print("Game over - no turn context")
107107
break
108108

109-
turn_context_history, actions_player_can_take, system_prompt, user_prompt, pre_discussion_vote_prompts = turn_context_result
109+
turn_context_history, actions_player_can_take, conversation, pre_discussion_vote_prompts = turn_context_result
110110

111111
# Ensure available tasks match between replay and original game
112112
# The tasks are stored in the history item's tasks_left_to_do attribute

scripts/run_benchmark.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from among_them.models.action_type import ActionType # type: ignore
3535
from among_them.models.player import Player # type: ignore
3636
from among_them.game_jsonencoder import game_object_hook # type: ignore
37-
from among_them.utils.prompt_utils import reconstruct_environment_prompt_from_history # type: ignore
37+
from among_them.utils.prompt_utils import build_conversation_for_player # type: ignore
3838
from among_them.utils.action_utils import get_task_phase_actions, get_vote_actions # type: ignore
3939
from among_them.models.phase import GamePhase # type: ignore
4040
from among_them.config import ( # type: ignore
@@ -69,10 +69,11 @@ def resolve_backend_model_name() -> str:
6969
return MODEL_NAME
7070

7171

72-
def try_reconstruct_full_prompt(example: Dict[str, Any], dataset_meta: Dict[str, Any]) -> Optional[str]:
73-
"""Reconstruct the full prompt from original game_state using example id and current_player.
72+
def try_reconstruct_conversation(example: Dict[str, Any], dataset_meta: Dict[str, Any]) -> Optional[List[dict]]:
73+
"""Reconstruct the conversation from original game_state using example id and current_player.
7474
7575
Expects example['id'] like 'game_state_10.json:52' and example['current_player'].
76+
Returns a conversation (list of message dicts) or None if reconstruction fails.
7677
"""
7778
ex_id = example.get('id', '')
7879
if ':' not in ex_id:
@@ -128,17 +129,17 @@ def try_reconstruct_full_prompt(example: Dict[str, Any], dataset_meta: Dict[str,
128129

129130
history_slice = history[: hist_idx + 1]
130131
try:
131-
return reconstruct_environment_prompt_from_history(current_player_obj, history_slice, players, game_config)
132+
return build_conversation_for_player(current_player_obj, history_slice, players, game_config)
132133
except Exception:
133134
return None
134135

135136

136-
def call_model_with_retry(full_prompt: str, exec_model_name: str, max_retries: int = 10) -> Tuple[str, Optional[str]]:
137+
def call_model_with_retry(conversation: List[dict], exec_model_name: str, max_retries: int = 10) -> Tuple[str, Optional[str]]:
137138
"""Call invoke_llm with simple retry/backoff on rate-limit (429) errors."""
138139
last_err: Optional[Exception] = None
139140
for attempt in range(max_retries):
140141
try:
141-
return invoke_llm(system_prompt="", prompt=full_prompt, model_name=exec_model_name, single_line_only=False)
142+
return invoke_llm(conversation=conversation, model_name=exec_model_name, single_line_only=False)
142143
except Exception as e:
143144
last_err = e
144145
msg = str(e)
@@ -212,16 +213,19 @@ def run_example(example: Dict[str, Any], exec_model_name: str, dataset_meta: Dic
212213
"""Run the model once on an example.
213214
Returns: (raw_output, chosen_idx, chosen_action_str)
214215
"""
215-
# Build prompts: we store full_prompt in the example; system prompt is included.
216-
full_prompt = example.get('full_prompt', '')
217-
if not full_prompt:
218-
# Reconstruct from original game state if possible
219-
full_prompt = try_reconstruct_full_prompt(example, dataset_meta) or ''
220-
if not full_prompt:
221-
raise ValueError(f"Example {example.get('id')} missing full_prompt")
222-
223-
# Our invoke_llm expects system_prompt and prompt; we pass system empty and put all into user prompt
224-
response_text, cot = call_model_with_retry(full_prompt, exec_model_name)
216+
# Try to get conversation from reconstruction first (returns List[dict])
217+
conversation = try_reconstruct_conversation(example, dataset_meta)
218+
219+
# Fall back to stored full_prompt (string) if reconstruction fails
220+
if not conversation:
221+
full_prompt = example.get('full_prompt', '')
222+
if not full_prompt:
223+
raise ValueError(f"Example {example.get('id')} missing full_prompt and cannot reconstruct")
224+
# Convert string prompt to conversation format
225+
conversation = [{"role": "user", "content": full_prompt}]
226+
227+
# Our invoke_llm expects conversation format
228+
response_text, cot = call_model_with_retry(conversation, exec_model_name)
225229

226230
actions = actions_from_strings(example.get('available_actions', []))
227231
if not actions:

src/among_them/game_engine.py

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
get_players_in_room)
2121
from among_them.utils.end_utils import get_end_game_reason
2222
from among_them.utils.history_utils import initialize_history, create_system_message
23-
from among_them.utils.prompt_utils import reconstruct_environment_prompt_from_history
2423
from among_them.game_config import GameConfig
2524

2625
class GameEngine:
@@ -44,7 +43,7 @@ def __init__(self, game_config: GameConfig = GameConfig(), file_path: Optional[s
4443
if file_path is not None:
4544
self.file_path = file_path
4645

47-
def get_turn_context(self, player_name: Optional[str] = None) -> Optional[tuple[History, List[Action], str, str, List[dict]]]:
46+
def get_turn_context(self, player_name: Optional[str] = None) -> Optional[tuple[History, List[Action], List[dict], List[dict]]]:
4847
"""Gathers all necessary context for the current player's turn.
4948
5049
This method calculates everything needed for a turn up-front to avoid
@@ -56,8 +55,8 @@ def get_turn_context(self, player_name: Optional[str] = None) -> Optional[tuple[
5655
Returns:
5756
A tuple containing:
5857
- An incomplete History object representing the current turn's context.
59-
- The system prompt for the LLM.
60-
- The user prompt for the LLM.
58+
- List of actions the player can take.
59+
- The conversation history with the new user message appended (List[Dict]).
6160
- A list of pre-discussion vote prompts (empty if not in DISCUSS phase).
6261
Or None if the game is in a state that doesn't require player input.
6362
"""
@@ -69,7 +68,7 @@ def get_turn_context(self, player_name: Optional[str] = None) -> Optional[tuple[
6968
# If game ended no further player input is required.
7069
if get_end_game_reason(self.history, self.players) is not None:
7170
print("Game over! {}".format(get_end_game_reason(self.history, self.players)))
72-
return None, None, None, None, None
71+
return None, None, None, None
7372

7473
current_player, next_player_names = get_next_random_player(self.history, self.players)
7574

@@ -104,7 +103,7 @@ def get_turn_context(self, player_name: Optional[str] = None) -> Optional[tuple[
104103
actions_player_can_take = get_vote_actions(self.history, self.players, current_player)
105104
location = Location.CAFETERIA
106105
else:
107-
return None, None, None, None, None
106+
return None, None, None, None
108107

109108
# Create a placeholder action for the current player
110109
placeholder_action = Action(player_name=current_player.name, type=ActionType.SPEAK, target_message="placeholder")
@@ -127,12 +126,33 @@ def get_turn_context(self, player_name: Optional[str] = None) -> Optional[tuple[
127126
votes_before_this_discussion_message={},
128127
)
129128

130-
# Build new environment-like prompt
131-
environment_prompt = reconstruct_environment_prompt_from_history(
129+
# Build conversation history from existing turns + new user message
130+
from among_them.utils.prompt_utils import build_conversation_for_player, get_initial_turn_prompt, get_incremental_observations
131+
132+
# Get past conversation turns for this player
133+
conversation = build_conversation_for_player(
132134
current_player, self.history, self.players, self.game_config
133135
)
134-
system_prompt = "" # System context is included in environment_prompt
135-
user_prompt = environment_prompt
136+
137+
# Find last turn index for this player to determine if this is first turn
138+
player_turn_indices = [i for i, event in enumerate(self.history) if event.action_taken.player_name == current_player.name]
139+
140+
# Generate new user message for current turn
141+
if not player_turn_indices:
142+
# First turn: include system prompt
143+
new_user_msg = get_initial_turn_prompt(
144+
current_player, self.history, self.players, self.game_config, len(self.history)
145+
)
146+
else:
147+
# Subsequent turn: only incremental observations
148+
last_turn_index = player_turn_indices[-1]
149+
new_user_msg = get_incremental_observations(
150+
current_player, self.history, self.players, self.game_config,
151+
last_turn_index, len(self.history)
152+
)
153+
154+
# Append new user message to conversation
155+
conversation.append({"role": "user", "content": new_user_msg})
136156

137157
pre_discussion_vote_prompts = []
138158
if phase == GamePhase.DISCUSS:
@@ -149,17 +169,38 @@ def get_turn_context(self, player_name: Optional[str] = None) -> Optional[tuple[
149169
alive_player_names=alive_player_names,
150170
)
151171
)
152-
voting_prompt = reconstruct_environment_prompt_from_history(
172+
173+
# Build conversation for this player up to voting phase
174+
voting_conversation = build_conversation_for_player(
153175
player, fake_history, self.players, self.game_config
154176
)
177+
178+
# Find last turn for this player in fake_history
179+
player_turn_indices = [i for i, event in enumerate(fake_history) if event.action_taken.player_name == player.name]
180+
181+
# Generate voting prompt message
182+
if not player_turn_indices:
183+
# First turn scenario (shouldn't happen in voting but handle it)
184+
voting_user_msg = get_initial_turn_prompt(
185+
player, fake_history, self.players, self.game_config, len(fake_history)
186+
)
187+
else:
188+
last_turn_index = player_turn_indices[-1]
189+
voting_user_msg = get_incremental_observations(
190+
player, fake_history, self.players, self.game_config,
191+
last_turn_index, len(fake_history)
192+
)
193+
194+
# Append the new voting message
195+
voting_conversation.append({"role": "user", "content": voting_user_msg})
196+
155197
pre_discussion_vote_prompts.append({
156198
"player": player,
157-
"system_prompt": "",
158-
"user_prompt": voting_prompt,
199+
"conversation": voting_conversation, # Now a conversation!
159200
"actions": fake_voting_actions
160201
})
161202

162-
return turn_history, actions_player_can_take, system_prompt, user_prompt, pre_discussion_vote_prompts
203+
return turn_history, actions_player_can_take, conversation, pre_discussion_vote_prompts
163204

164205
def step(self, turn_context: History, action_taken: Action, llm_response: str = "", llm_cot: str = "", token_usage: dict = {}, pre_discussion_votes: Optional[dict] = None) -> tuple[bool, Optional[EndGameReason]]:
165206
"""Executes a single player action and updates the game state using pre-calculated context.

src/among_them/utils/llm_utils.py

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,29 @@
66

77

88
def invoke_llm(
9-
system_prompt: str,
10-
prompt: str,
9+
conversation: List[dict],
1110
model_name: str,
1211
allowed_actions: Optional[List[str]] = None,
1312
single_line_only: bool = False,
1413
max_output_chars: Optional[int] = None,
1514
) -> Tuple[str, Optional[str]]:
1615
"""
17-
Invoke the LLM with the given prompts and handle exceptions.
16+
Invoke the LLM with the given conversation history and handle exceptions.
1817
1918
Args:
20-
system_prompt: The system prompt to use
21-
prompt: The user prompt to send to the LLM
19+
conversation: List of message dicts [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]
2220
model_name: The model name to use
21+
allowed_actions: Optional list of allowed actions for validation
22+
single_line_only: Whether to enforce single-line responses
23+
max_output_chars: Maximum output characters allowed
2324
2425
Returns:
2526
Tuple of (response_text, chain_of_thought)
2627
2728
Raises:
2829
ValueError: If no chain of thought is found in the response
2930
"""
30-
messages = [
31-
{"role": "user", "content": system_prompt + "\n" + prompt},
32-
]
33-
34-
# Print chat history (ensure terminal color is reset beforehand)
35-
print("\033[0m", end="")
36-
print("\n\nChat history:\n")
37-
for message in messages:
38-
print(f"{message['role']}: {message['content']}")
31+
messages = conversation
3932

4033
raw = "<think>" if LLM_BACKEND == LLMBackend.MLX else ""
4134
raw_reasoning = ""

0 commit comments

Comments
 (0)