Research harness for studying how communication topology and prompt framing affect multi-agent LLM disposition (cooperation vs defection). Currently ships two games — the Traveler's Dilemma and a linear Public Goods Game — an optional cheap-talk negotiation phase before decisions, and is built to scale to N agents, mixed topologies, and mixed model backends without rewriting the orchestration layer.
| File | Responsibility |
|---|---|
schemas.py |
Shared pydantic types: Action (game-agnostic value + reasoning), Message (one cheap-talk utterance), Observation, NegotiationObservation, RoundRecord |
game.py |
Pure payoff logic. Game.compute_payoffs(values) -> payoffs. TravelersDilemma and PublicGoodsGame implemented; drop in more Game subclasses the same way |
topology.py |
Who plays whom. Topology.interaction_groups(round_num) -> [(agent_id, ...), ...]. pair used now; fully_connected/star/ring ready for N agents |
prompts.py |
Naive vs game-theory-primed system prompts, history rendering, negotiation prompts, JSON-output instructions — all config-selected |
agent.py |
ModelBackend (Ollama / llama.cpp / Mock) + LLMAgent.act(observation) -> Action and LLMAgent.speak(observation) -> Message, with JSON parse validation and retry-with-repair on act |
orchestrator.py |
Loads a config, builds game/topology/agents, runs the optional negotiation phase then the decision phase each round, logs |
logging_utils.py |
JSONL logger + config hashing |
analysis.py |
Loads logs into pandas, splits decision rows from cheap-talk rows, plots value/payoff trajectories, prints negotiation transcripts |
pip install -r requirements.txtPick a model backend — local or hosted, mix and match per agent:
- llama.cpp: download a GGUF build of Qwen2.5-1.5B-Instruct into
models/, e.g. from a Hugging Face GGUF repo, and pointmodel_pathat it. - Ollama (default in the example configs):
ollama pull qwen2.5:1.5b(or any model), then setbackend: ollamaandmodel: qwen2.5:1.5b. - Anthropic (hosted Claude): set
backend: anthropicandmodel: claude-opus-4-8(orclaude-haiku-4-5for a cheaper/faster model). NeedsANTHROPIC_API_KEYset, orant auth login. Each round is a billed API call — seeconfig/pair_primed_iterated_qwen_vs_claude.yamlfor a heterogeneous local-vs-hosted trial.
No model installed yet? Run the smoke test first — it uses MockBackend
(random legal claims, no model calls) to verify the whole pipeline works:
python orchestrator.py --config config/pair_mock_smoketest.yaml --verbose
python analysis.py "logs/pair_mock_smoketest_*.jsonl"# Traveler's Dilemma
python orchestrator.py --config config/pair_naive_stateless.yaml --verbose
python orchestrator.py --config config/pair_primed_iterated.yaml --verbose
# Public Goods Game
python orchestrator.py --config config/pgg_naive_stateless.yaml --verbose
python orchestrator.py --config config/pgg_primed_iterated.yaml --verbose
# Traveler's Dilemma with cheap talk before decisions
python orchestrator.py --config config/pair_iterated_cheaptalk_per_round.yaml --verbose
python orchestrator.py --config config/pair_stateless_cheaptalk_once.yaml --verbose
# Heterogeneous trial: local Qwen vs hosted Claude (needs ANTHROPIC_API_KEY)
python orchestrator.py --config config/pair_primed_iterated_qwen_vs_claude.yaml --verbose
python analysis.py "logs/*.jsonl" --out figures/Each run writes logs/<experiment_name>_<config_hash>.jsonl — one JSON row
per agent per round, with value (the claim or contribution, whichever the
game calls for), payoff, full reasoning, timestamp, config_hash,
priming, memory, model, and parse_failed. When cheap_talk is
enabled, negotiation rows are interleaved in the same file (phase: "cheap_talk", with a message field instead of value/payoff) ahead of
each round's phase: "decision" rows. analysis.py splits the two phases
automatically, prints a per-agent decision summary table, writes
value/payoff trajectory plots to figures/, and prints the first few
rounds' negotiation transcripts for a qualitative read.
Add a cheap_talk block to any config to let agents exchange non-binding
messages before submitting their claim/contribution:
cheap_talk:
enabled: true
frequency: per_round # "per_round": renegotiate before every round
# "once": negotiate only before round 1, then that
# same transcript is shown before every
# subsequent round's decision
turns: 1 # messages per side per negotiation phase (2 turns
# = a short back-and-forth, e.g. propose + respond)Messages are generated with LLMAgent.speak() — free text, no JSON
required, since any string is a "valid" message. Negotiation visibility
follows the same memory (stateless/iterated) gating as decisions for
past rounds' messages, but the current round's freshly-exchanged
transcript is always shown before that round's decision regardless of
memory — otherwise there'd be no point negotiating. See
config/pair_iterated_cheaptalk_per_round.yaml (repeated renegotiation,
primed + iterated) and config/pair_stateless_cheaptalk_once.yaml (a
single pre-play agreement layered onto otherwise-anonymous stateless
rounds — the classic "does cheap talk work" setup from the literature) for
both frequencies in action.
game:type+ params —travelers_dilemma:bonus,claim_min,claim_maxpublic_goods:endowment,multiplier(keep1 < multiplier < group_sizefor the standard free-ride-is-dominant social dilemma)
topology:type(pairfor phase 1) +agent_idsrounds: number of roundspriming:naive|primed(experiment default; overridable per agent)memory:stateless|iterated(experiment default; overridable per agent)agents.<id>:backend(llama_cpp|ollama|anthropic|mock) + backend params, plus optional per-agentpriming/memory/system_promptoverrides — this is how you set up heterogeneous-model trials
- More agents / a new topology: add agent ids under
topology.agent_idsandagents, changetopology.typetofully_connected,star, orring(or add a newTopologysubclass). The orchestrator loop is already generic overinteraction_groups(). Notefully_connected/star/ringcurrently emit pairwise groups — a proper N-player Public Goods Game (one shared pot for the whole group) needs a topology that returns a single N-agent group instead; that's a small addition, not built yet. - A new game: implement a new
Gamesubclass ingame.py—compute_payoffsalready takes an arbitrary-size group dict (seePublicGoodsGame, which works for N >= 2 with no orchestrator changes). Register it inGAME_REGISTRYand pointgame.typeat it in a config. - More hosted API models:
AnthropicBackendinagent.pyalready wraps the Claude API — for another provider (e.g. OpenAI), add a newModelBackendsubclass the same way and register it inBACKEND_REGISTRY; nothing else changes.