Tutorial
This tutorial will guide you through setting up and running the Prisoner's Dilemma experiment included in the examples/prisoner directory.
For more examples, see the econagents cookbook.
Prerequisites
Before running an experiment, ensure you have:
Python 3.10+ installed
All dependencies installed
Have set up API keys for OpenAI and LangSmith
Create a .env file in your project root with the following variables:
LANGCHAIN_API_KEY=<your_langsmith_api_key>
LANGSMITH_TRACING=true
LANGSMITH_ENDPOINT="https://api.smith.langchain.com"
LANGSMITH_PROJECT="econagents"
OPENAI_API_KEY=<your_openai_api_key>
Understanding the Prisoner's Dilemma Experiment
The Prisoner's Dilemma is a classic game theory scenario where two players must decide whether to cooperate or defect simultaneously. The experiment in this repository demonstrates how AI agents can be used in this scenario to observe their decision-making processes.
The experiment consists of:
A game server that manages the game state and rules
AI agents that play the roles of prisoners
A game runner that coordinates the interaction between agents and the server
How State Management Works
The Prisoner's Dilemma experiment uses the econagents framework's hierarchical state management system. In examples/prisoner/state.py, you can find the three components:
class PDMeta(MetaInformation):
# Game metadata, including game ID, current round, and total rounds
game_id: int = EventField(default=0, exclude_from_mapping=True)
phase: int = EventField(default=0, event_key="round")
total_rounds: int = EventField(default=5)
class PDPrivate(PrivateInformation):
# Player-specific information not visible to other players
total_score: int = EventField(default=0)
class PDPublic(PublicInformation):
# Information visible to all players
history: list[dict[str, Any]] = EventField(default_factory=list)
class PDGameState(GameState):
# Main game state that combines all components
meta: PDMeta = Field(default_factory=PDMeta)
private_information: PDPrivate = Field(default_factory=PDPrivate)
public_information: PDPublic = Field(default_factory=PDPublic)
The state gets updated automatically through the EventField system, which maps data from incoming events from the server to state fields.
For example, in our server implementation, the server sends events like after each round finishes:
{
"meta": {"type": "round-result"},
"payload": {
"gameId": 1743761219,
"round": 1,
"choices": {
"1": "cooperate",
"2": "cooperate"
},
"payoffs": {
"1": 3,
"2": 3
},
"total_score": 3,
"history": [
{"round": 1, "my_choice": "cooperate", "opponent_choice": "cooperate", "my_payoff": 3, "opponent_payoff": 3}
]
}
}
The framework reads the event type from meta.type and the event data from payload. In this case, the EventField system updates the phase (using the round key) in PDMeta, total_score in PDPrivate, and history in PDPublic state. The payoffs key is ignored, because it was not included in the state definition.
Agent Implementation
The prisoner example defines a role and an agent factory:
from econagents import Role
from econagents.runtime import Agent, GameRunnerConfig, create_game_state
class Prisoner(Role):
role = 1
name = "Prisoner"
llm = ChatOpenAI()
def create_prisoner_agent(config: GameRunnerConfig, recovery_code: str):
return Agent(
url=config.server_url(),
auth_mechanism=config.auth_mechanism,
auth_mechanism_kwargs={"gameId": config.game_id, "recovery": recovery_code},
state=create_game_state(PDGameState, game_id=config.game_id),
role=Prisoner(),
prompts_dir=config.prompts_dir,
phase_transition_event=config.phase_transition_event,
phase_identifier_key=config.phase_identifier_key,
)
The agent connects to the game server, maintains local state, and orchestrates actions based on server events. When a new round starts, the agent projects the event into state and prompts the role to make a decision.
The connection authenticates with the default JoinPayloadAuth, which
sends {"meta": {"type": "join"}, "payload": {...}} built from auth_mechanism_kwargs.
During the introduction phase the agent declares itself ready by returning
ready_message()
({"meta": {"type": "ready", "component": {"type": "standard:ready"}}, "payload": {}}).
To customize the handshake, register your own phase handler:
from econagents import INTRODUCTION_PHASE, build_message
async def custom_ready(phase, state) -> dict:
return build_message("ready", component="standard:ready")
agent.register_phase_handler(INTRODUCTION_PHASE, custom_ready)
Prompt System and Agent Behavior
The Prisoner's Dilemma example uses template-based prompts located in examples/prisoner/prompts/ to define the agent's behavior.
System Prompt (
all_system.jinja2): Sets up the agent's role and explains the game rules:You are playing the role of a criminal who has been arrested and is being interrogated by the police... In each round, you will need to choose between: - **Cooperate**: Remain silent (don't betray your partner) - **Defect**: Testify against your partner Your payoffs depend on both your choice and your partner's choice: - Both cooperate: You get 3, opponent gets 3 - You cooperate, opponent defects: You get 0, opponent gets 5 - You defect, opponent cooperates: You get 5, opponent gets 0 - Both defect: You get 1, opponent gets 1
User Prompt (
all_user.jinja2): Provides the current game state and instructions for the current round:# Make Your Choice ## Current Game State Round {{ meta.phase }} of {{ meta.total_rounds }} rounds Your current score: {{ private_information.total_score }} ## Your History {% if public_information.history %} Previous rounds: {% for round in public_information.history %} Round {{round.round}}: You chose **{{ round.my_choice}}**, opponent chose **{{ round.opponent_choice }}**. You earned {{ round.my_payoff }} points. {% endfor %} {% else %} This is the first round. {% endif %} ## Instructions Based on the current game state and your strategy, please choose whether to **cooperate** or **defect** in this round. Respond with only one of the following: 1. "COOPERATE" - if you choose to remain silent (cooperate) 2. "DEFECT" - if you choose to testify against the other player (defect) Respond with a JSON message of exactly this shape: ```json { "meta": {"type": "submit-choice", "component": {"type": "standard:coordination"}}, "payload": {"choice": "COOPERATE"} } ```
These templates leverage Jinja2 to dynamically insert the current game state. The agent's decision-making process follows the prompt resolution logic described in Customizing Roles:
The system looks for phase-specific prompts first
If none are found, it falls back to general prompts
The LLM receives both system and user prompts and generates a response
The response is parsed into a dictionary by the default
parse_phase_llm_responseand sent to the server
Rather than writing a custom parser, make the response schema the outbound message
envelope. The default parse_phase_llm_response returns response.model_dump() for a
structured response, so the validated schema is the message that gets sent — no custom
parser needed:
from typing import Literal
from pydantic import BaseModel, Field
from econagents import Role
class Component(BaseModel):
type: Literal["standard:coordination"] = "standard:coordination"
class ChoiceMeta(BaseModel):
type: Literal["submit-choice"] = "submit-choice"
component: Component = Field(default_factory=Component)
class ChoicePayload(BaseModel):
choice: Literal["COOPERATE", "DEFECT"]
class SubmitChoice(BaseModel):
meta: ChoiceMeta = Field(default_factory=ChoiceMeta)
payload: ChoicePayload
class Prisoner(Role):
role = 1
name = "Prisoner"
llm = ChatOpenAI()
default_response_schema = SubmitChoice
The LLM produces {"meta": {"type": "submit-choice", "component": {"type": "standard:coordination"}}, "payload": {"choice": "COOPERATE"}} directly.
Running the Experiment
Step 1: Start the Game Server
First, you need to start the Prisoner's Dilemma game server. The server defines the game logic and handles the communication between agents.
# From the project root, start the server
uv run python examples/prisoner/server/server.py
This will start a WebSocket server on localhost port 8765. The server has methods to create a new game and generate recovery codes that agents use to join the game.
Step 2: Run the Prisoner's Dilemma Game
Once the server is running, you can start the game with AI agents. The game runner will:
Create a game by connecting to the server
Initialize AI agents with the appropriate roles
Handle the turn-based game flow
Log interactions for analysis
To run the game, open a new terminal and run:
# From the project root, run the game
uv run python examples/prisoner/run_game.py
This will start the game runner, which will connect to the server and start the game. You should run this in a new terminal, and keep the server running in the other terminal.
Behind the scenes, here's what happens:
The
run_game.pyscript creates a game on the server viacreate_game_from_specs()It initializes a
TurnBasedGameRunnerConfigwith paths to logs and promptsIt creates one
Agentfor each player with appropriate authenticationThe
GameRunnerstarts all agents and supervises the game flowWhen a new round starts, each agent receives the current state and makes a decision
The server processes the decisions and updates the game state
This cycle continues until all rounds are completed
Step 3: Analyzing the Results
After the game completes, you can analyze the results by:
Checking the logs in the
examples/prisoner/logsdirectoryIn LangSmith, you can view the full interaction history and decision-making processes in your LangSmith dashboard
The logs contain detailed information about: - Agent decisions in each round - Game state updates after each round - Outcomes and scores
Customizing the Experiment
You can customize several aspects of the experiment:
Modifying Agent Prompts
Edit the templates in examples/prisoner/prompts/ to change the agent's behavior:
Change the payoff matrix in
all_system.jinja2to explore different incentive structures (don't forget to update the game logic in server.py)Modify the instructions in
all_user.jinja2to guide the agent toward specific strategiesCreate phase-specific prompts like
all_system_phase_3.jinja2to change behavior in specific rounds
You can also define new roles (e.g., Cooperator) and create role-specific prompts (e.g., cooperator_system.jinja2) to customize the agent's behavior.
You can also use the methods described in Customizing Roles to create more sophisticated agents with phase-specific behaviors.
Modifying Game Rules
For more advanced usage, you can:
Create your own game server for different economic experiments
Customize roles with different personalities or strategies. For example, check out the public goods game example <https://github.com/IBEX-TUDelft/econagents/tree/main/examples/public_goods> for an scenario where each agent has a different personality and strategy.
Implement more complex game rules and state management
Explore multi-agent scenarios with more than two players
Refer to the documentation on Agents, Managing State, and Customizing Roles for more details.