Bilateral Trade¶
Game ID: bilateral-trade
An initiator and one or more providers negotiate scope and price for a service, then the selected provider delivers and the initiator verifies. Payment is released on acceptance; disputes follow a configurable resolution policy.
Modes¶
The game supports three modes:
- Full — Classic (2 agents): One initiator and one provider exchange alternating proposals until one side accepts or walks away. Set
mode: full(default). - Full — Competitive (3+ agents): One initiator and multiple providers. Providers submit independent proposals and the initiator selects a winner. Set
mode: fullwith 3+ agents. - Price Only (
mode: price_only): A simplified mode where scope is fixed and agents negotiate only on price. A buyer and seller alternate turns — the seller proposes prices, the buyer accepts or rejects. No request/deliver/verify phases.
Why This Game Matters for Multi-Agent Systems¶
Bilateral trade models the most common interaction pattern in agent systems: one agent needs a capability it doesn't have and must contract with another agent to get it done.
Service Procurement — An orchestrator needs a specialist (translation, code review, data enrichment) and must negotiate terms before work begins. Unlike a simple auction, both scope and price are negotiable.
API-to-API Contracting — Two autonomous services negotiate a data exchange: what data, in what format, at what cost. The initiator verifies the response meets the agreed specification before releasing payment tokens.
Competitive Sourcing — With 3+ agents, the game becomes a competitive marketplace: multiple providers bid for work, creating price competition and incentivising quality proposals.
Abstract Scenario (Classic)¶
An INITIATOR posts a request describing what they need and their maximum budget. A PROVIDER reviews the request and proposes scope and price. They negotiate via alternating proposals until one side accepts or walks away. On agreement, the provider delivers and the initiator verifies. Payment is released on acceptance; disputes follow the configured resolution policy.
Abstract Scenario (Competitive)¶
An INITIATOR posts a request describing what they need and their maximum budget. Multiple PROVIDERS independently submit proposals with scope and price. The INITIATOR compares proposals and selects the best one. The selected provider delivers and the initiator verifies. Non-selected providers receive utility 0.
Abstract Scenario (Price Only)¶
A BUYER and a SELLER negotiate the price for a predefined task. The task scope is fixed — only the price is negotiable. The SELLER proposes prices; the BUYER accepts or rejects. Each agent has a private reservation value. On agreement at price P: buyer utility =
max_budget - P, seller utility =P - seller_RV. If negotiations fail, everyone gets 0.
Rules¶
Full mode (default)¶
- 2+ agents required: initiator (index 0), provider(s) (index 1+).
- 4 phases: request → negotiate → deliver → verify.
- The initiator posts a request with a description and maximum budget.
- Classic (2 agents): Either party can propose
{scope, price}during negotiation. Either party can accept the other's proposal (cannot accept your own). - Competitive (3+ agents): Only providers propose. The initiator selects a winner with
accept_proposal(provider_id=...). Providers can exit individually; if all exit the game ends. - Price must always be <= max_budget.
- On agreement, the (selected) provider submits a deliverable.
- The initiator accepts (releasing full payment) or disputes the delivery.
- Dispute resolution:
no_payment(default) orsplit(50/50 of agreed price). - Payoffs on acceptance: initiator = -price, selected provider = +price, others = 0.
Price-only mode¶
- 2 agents required: buyer (index 0) and seller (index 1).
- 1 phase: negotiate (up to 100 rounds).
- The task scope is fixed via
fixed_scope— no scope negotiation. - Only the seller can
propose(price only). The buyer canaccept_proposalorreject_and_exit. - Price must be >= 0 and <=
max_budget. - Each agent has a private reservation value (set via
reservation_values). - Payoffs on agreement at price P: buyer =
max_budget - P, seller =P - seller_RV. - Payoffs on failure: all get 0.
Actions¶
Full mode¶
| Action | Payload | Phase | Role | Description |
|---|---|---|---|---|
post_request |
{description, max_budget} |
request | initiator | Describe the task and set the budget cap. |
propose |
{scope, price} |
negotiate | classic: either; competitive: providers only | Propose scope and price. Price must be <= max_budget. |
accept_proposal |
{provider_id?} |
negotiate | classic: either; competitive: initiator | Accept a proposal. In competitive mode, provider_id is required. |
reject_and_exit |
{reason} |
negotiate | either | Walk away. In classic mode the game ends. In competitive mode, a provider is removed from the pool. |
submit_deliverable |
{content} |
deliver | selected provider | Submit the completed work. |
accept_delivery |
{} |
verify | initiator | Accept the deliverable and release payment. |
dispute_delivery |
{reason} |
verify | initiator | Dispute the deliverable. |
message_only |
{} |
any | either | Send messages without advancing the turn. In competitive negotiate phase, this also advances the turn. |
Price-only mode¶
| Action | Payload | Phase | Role | Description |
|---|---|---|---|---|
propose |
{price} |
negotiate | seller | Propose a price for the task. Price must be <= max_budget. |
accept_proposal |
{} |
negotiate | buyer | Accept the seller's current price proposal. |
reject_and_exit |
{reason} |
negotiate | either | Walk away; game ends with payoffs 0. |
pass |
{} |
negotiate | either | Skip your turn without acting. |
message_only |
{} |
negotiate | either | Send messages without advancing the turn. |
Visible game state¶
Full mode¶
Both agents see the same state:
# Classic (2 agents)
{
"request": {
"description": "Translate 10 pages from English to French",
"max_budget": 50,
},
"proposal": {
"scope": "Translate all 10 pages with proofreading",
"price": 35,
"proposed_by": "provider_agent",
},
"agreement": {"scope": "...", "price": 35, "proposed_by": "..."},
"deliverable": "Here are the translated pages...",
"delivery_accepted": True,
"action_history": [...],
}
# Competitive (3+ agents) — additional fields
{
"proposals": {
"provider_1": {"scope": "...", "price": 30},
"provider_2": {"scope": "...", "price": 25},
},
"active_providers": ["provider_1", "provider_2"],
"selected_provider": "provider_2",
# ... plus all classic fields
}
Price-only mode¶
{
"num_agents": 2,
"agent_ids": ["agent_1", "agent_2"],
"buyer": "agent_1",
"seller": "agent_2",
"my_role": "buyer", # "buyer" or "seller"
"fixed_scope": "Complete a predefined software development task",
"max_budget": 100,
"my_reservation_value": 30.0, # private — only you see yours
"proposal": {"scope": "...", "price": 65, "proposed_by": "agent_2"},
"agreement": None, # set when buyer accepts
"action_history": [...],
}
Turn order¶
The negotiation phase turn order is configurable:
| Mode | Behavior |
|---|---|
round_robin (default) |
Strict turn order during negotiation. Out-of-turn actions are rejected. |
random |
Runner picks a random agent each turn. All eligible agents can act (no turn enforcement). In competitive mode, any active provider can propose and the initiator can act once proposals exist. |
Other phases (request, deliver, verify) use ROUND_ROBIN turn order — the game automatically sets the turn index to the role-appropriate agent.
Configuration¶
from neg_env import BilateralTradeGame
# Classic bilateral trade (2 agents, default mode="full")
game = BilateralTradeGame()
# Competitive trade (use the same class — mode is determined by agent count)
game = BilateralTradeGame(dispute_resolution="split")
# Random turn order during negotiation
game = BilateralTradeGame(negotiate_turn_order="random")
# Price-only mode — fixed scope, negotiate only on price
game = BilateralTradeGame(
mode="price_only",
fixed_scope="Complete a predefined software development task",
max_budget=100,
reservation_values={"buyer": 30, "seller": 30},
)
| Parameter | Type | Default | Description |
|---|---|---|---|
mode |
str |
"full" |
"full" (classic/competitive) or "price_only" (simplified price negotiation). |
dispute_resolution |
str |
"no_payment" |
"no_payment" or "split" (50/50 of agreed price on dispute). Full mode only. |
negotiate_turn_order |
str |
"round_robin" |
Turn order for the negotiation phase: "round_robin" or "random". Full mode only. |
fixed_scope |
str |
"" |
Task description visible to both agents. Price-only mode only. |
max_budget |
float |
100 |
Buyer's maximum budget. Price-only mode only. |
reservation_values |
dict |
None |
Private reservation values per agent. Price-only mode only. |
Hydra YAML configuration¶
# conf/game/bilateral_trade.yaml (price_only)
game_id: bilateral-trade
mode: price_only
fixed_scope: "Complete a predefined software development task"
max_budget: 100
reservation_values:
agent_1: 30
agent_2: 30
dispute_resolution: no_payment
negotiate_turn_order: round_robin
Outcome format¶
# Trade completed
{
"payoffs": [
{"agent_id": "initiator", "utility": -35.0},
{"agent_id": "provider", "utility": 35.0},
],
"reason": "trade_completed",
}
# Delivery disputed (no_payment)
{
"payoffs": [
{"agent_id": "initiator", "utility": 0.0},
{"agent_id": "provider", "utility": 0.0},
],
"reason": "delivery_disputed_no_payment",
}
# Delivery disputed (split)
{
"payoffs": [
{"agent_id": "initiator", "utility": -17.5},
{"agent_id": "provider", "utility": 17.5},
],
"reason": "delivery_disputed_split",
}
# Walk away (classic) or timeout
{
"payoffs": [...],
"reason": "negotiation_failed", # or "max_rounds_exceeded"
}
# Competitive: initiator exits
{
"payoffs": [{"agent_id": a, "utility": 0.0} for a in agents],
"reason": "initiator_exited",
}
# Competitive: all providers exit
{
"payoffs": [{"agent_id": a, "utility": 0.0} for a in agents],
"reason": "all_providers_exited",
}
# Price-only: trade completed at price 65 (max_budget=100, seller_RV=30)
{
"payoffs": [
{"agent_id": "buyer", "utility": 35.0}, # 100 - 65
{"agent_id": "seller", "utility": 35.0}, # 65 - 30
],
"reason": "trade_completed",
}
Prompts¶
Classic (2 agents):
SYSTEM_PROMPT_INITIATOR— post clear requests, negotiate price, verify deliverySYSTEM_PROMPT_PROVIDER— understand the task, propose realistic scope, deliver quality work
Competitive (3+ agents):
from neg_env.prompts import (
SYSTEM_PROMPT_COMPETITIVE_INITIATOR,
SYSTEM_PROMPT_COMPETITIVE_PROVIDER,
SYSTEM_PROMPT_COMPETITIVE_PROVIDER_COLLUDER,
)
SYSTEM_PROMPT_COMPETITIVE_INITIATOR— post requests, compare proposals, select the best providerSYSTEM_PROMPT_COMPETITIVE_PROVIDER— compete on scope and price, deliver if selectedSYSTEM_PROMPT_COMPETITIVE_PROVIDER_COLLUDER— uses private messages to coordinate pricing with other providers
Price-only mode (Hydra presets):
bilateral_trade/buyer_rational— rational buyer that negotiates for the lowest pricebilateral_trade/seller_rational— rational seller that negotiates for the highest price
Example (Classic)¶
from pathlib import Path
from dotenv import load_dotenv
from neg_env import ExperimentConfig, ExperimentRunner, LangChainNegotiationAgent
from neg_env.prompts import SYSTEM_PROMPT_INITIATOR, SYSTEM_PROMPT_PROVIDER
from neg_env.games.bilateral_trade import BilateralTradeGame
load_dotenv()
agents = [
LangChainNegotiationAgent(
agent_id="initiator", provider="openai",
model="gpt-4o-mini", system_prompt=SYSTEM_PROMPT_INITIATOR,
),
LangChainNegotiationAgent(
agent_id="provider", provider="openai",
model="gpt-4o-mini", system_prompt=SYSTEM_PROMPT_PROVIDER,
),
]
game = BilateralTradeGame(dispute_resolution="no_payment")
config = ExperimentConfig(
game_id="bilateral-trade", num_matches=5,
log_directory=Path("./logs/bilateral_trade"), open_dashboard=True,
)
result = ExperimentRunner(config).run(agents, game=game)
Example (Competitive)¶
from pathlib import Path
from dotenv import load_dotenv
from neg_env import ExperimentConfig, ExperimentRunner, LangChainNegotiationAgent
from neg_env.prompts import (
SYSTEM_PROMPT_COMPETITIVE_INITIATOR,
SYSTEM_PROMPT_COMPETITIVE_PROVIDER,
)
from neg_env.games.bilateral_trade import BilateralTradeGame
load_dotenv()
agents = [
LangChainNegotiationAgent(
agent_id="initiator", provider="openai",
model="gpt-4o-mini", system_prompt=SYSTEM_PROMPT_COMPETITIVE_INITIATOR,
),
LangChainNegotiationAgent(
agent_id="provider_1", provider="openai",
model="gpt-4o-mini", system_prompt=SYSTEM_PROMPT_COMPETITIVE_PROVIDER,
),
LangChainNegotiationAgent(
agent_id="provider_2", provider="openai",
model="gpt-4o-mini", system_prompt=SYSTEM_PROMPT_COMPETITIVE_PROVIDER,
),
]
game = BilateralTradeGame(dispute_resolution="no_payment")
config = ExperimentConfig(
game_id="bilateral-trade", num_matches=2,
log_directory=Path("./logs/competitive_trade"),
open_dashboard=True, max_turns_per_match=30, max_messages_per_turn=10,
)
result = ExperimentRunner(config).run(agents, game=game)