feat: Add AI Negotiation Battle Simulator

A real-time agent vs agent negotiation showdown using Google ADK:

- Two AI agents (Buyer vs Seller) negotiate autonomously
- Dramatic used car scenario with secrets on both sides
- 4 buyer personalities (Desperate Dan, Analytical Alex, Cool-Hand Casey, Fair-Deal Fran)
- 4 seller personalities (Shark Steve, By-The-Book Beth, Motivated Mike, Drama Queen Diana)
- 3 negotiation scenarios (Used Car, Vintage Guitar, Apartment Sublet)
- Configurable max rounds and personality selection
- Live Streamlit UI showing offers, counteroffers, and outcomes
- Uses gemini-3-flash-preview model

Run with: streamlit run negotiation_app.py
This commit is contained in:
awesomekoder
2026-02-01 18:36:34 -08:00
parent 60b72c6479
commit fb3ed57b21
11 changed files with 1642 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# Get your API key from https://aistudio.google.com/
GOOGLE_API_KEY=your_google_ai_studio_key_here

View File

@@ -0,0 +1,235 @@
# 🎮 AI Negotiation Battle Simulator
### A Real-Time Agent vs Agent Showdown!
Watch two AI agents battle it out in an epic used car negotiation! One agent desperately wants that sweet 2019 Honda Civic, the other is determined to squeeze every last dollar. Who will crack first? 🚗💰
## ✨ Features
- **🤖 Dual AI Agents**: Buyer vs Seller with distinct personalities and strategies
- **🔄 A2A Protocol Ready**: Demonstrates Google's Agent-to-Agent protocol for cross-agent communication
- **📊 Live Negotiation Tracking**: Watch offers, counteroffers, and dramatic moments unfold
- **🎭 Configurable Personalities**: From "Desperate First-Time Buyer" to "Ruthless Used Car Dealer"
- **🎬 Dramatic Scenarios**: Pre-built scenarios with backstories and stakes
## 🏗️ Architecture
```
┌─────────────────────────────────────────────────┐
│ Streamlit UI │
│ Buyer Panel │ Timeline │ Seller Panel │
└────────┬────────────────────────────┬───────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Buyer Agent │◄────────►│ Seller Agent │
│ (Google ADK) │ A2A/ │ (Google ADK) │
│ │ Direct │ │
│ • Budget: $12k │ │ • Min: $14k │
│ • Strategy: 🎯 │ │ • Strategy: 💰 │
└─────────────────┘ └─────────────────┘
│ │
└──────────┬─────────────────┘
┌─────────────────┐
│ Orchestrator │
│ (Manages Flow) │
└─────────────────┘
```
## 🚀 Quick Start
### 1. Clone and Navigate
```bash
git clone https://github.com/Shubhamsaboo/awesome-llm-apps.git
cd advanced_ai_agents/multi_agent_apps/ai_negotiation_battle_simulator
```
### 2. Install Dependencies
```bash
pip install -r requirements.txt
```
### 3. Set Up Environment
Create a `.env` file:
```bash
GOOGLE_API_KEY=your_google_ai_studio_key_here
```
Get your API key from [Google AI Studio](https://aistudio.google.com/)
### 4. Run the Battle!
```bash
streamlit run negotiation_app.py
```
## 🎭 The Scenario: "The Craigslist Showdown"
**THE CAR**: 2019 Honda Civic EX, 45,000 miles, one owner, minor scratch on bumper
**THE BUYER** 🎯:
- Recently graduated, needs reliable car for new job
- Has exactly $12,500 saved (with $500 emergency buffer)
- Found 3 similar cars online priced $13,000-$16,000
- *Secret*: Job starts Monday. Desperately needs a car.
**THE SELLER** 💰:
- Upgrading to an SUV, needs to sell the Civic first
- Paid $22,000 new, KBB says $14,500 private party
- Has one other interested buyer coming tomorrow
- *Secret*: The other buyer is flaky and might not show.
**THE STAKES**: Both have secrets. Both have pressure. Only one deal can be made.
## ⚙️ Configuration Options
### Negotiation Settings
| Setting | Options | Description |
|---------|---------|-------------|
| **Buyer Strategy** | Aggressive, Balanced, Patient | How pushy the buyer is |
| **Seller Strategy** | Firm, Flexible, Desperate | How willing to negotiate |
| **Max Rounds** | 3-15 | How many back-and-forths before walkaway |
| **Initial Offer** | % of asking | Where buyer starts |
| **Drama Level** | 🎭 to 🎭🎭🎭 | How theatrical the agents get |
### Preset Personalities
**Buyers:**
- 😰 *Desperate Dan* - Needs car TODAY, weak poker face
- 🧮 *Analytical Alex* - Cites every data point, very logical
- 😎 *Cool-Hand Casey* - Master of the walkaway bluff
- 🤝 *Fair-Deal Fran* - Just wants a win-win
**Sellers:**
- 🦈 *Shark Steve* - Never drops more than 5%, take it or leave it
- 📊 *By-The-Book Beth* - Goes strictly by KBB, reasonable but firm
- 😅 *Motivated Mike* - Really needs to sell, more flexible
- 🎭 *Drama Queen Diana* - Everything is "my final offer" (it's not)
## 📁 Project Structure
```
ai_negotiation_battle_simulator/
├── README.md # This file
├── requirements.txt # Dependencies
├── .env.example # Environment template
├── negotiation_app.py # Main Streamlit application
├── agents/
│ ├── __init__.py
│ ├── buyer_agent.py # Buyer agent with negotiation tools
│ ├── seller_agent.py # Seller agent with pricing tools
│ └── orchestrator.py # Manages negotiation flow
├── config/
│ ├── __init__.py
│ ├── personalities.py # Agent personality presets
│ └── scenarios.py # Negotiation scenarios
└── utils/
├── __init__.py
└── negotiation_state.py # State management
```
## 🔄 A2A Protocol Mode (Advanced)
For true cross-process agent communication, you can run agents as separate A2A servers:
### Terminal 1: Start Seller Agent
```bash
python -m agents.seller_agent --port 8001
```
### Terminal 2: Start Buyer Agent
```bash
python -m agents.buyer_agent --port 8002
```
### Terminal 3: Run Orchestrator
```bash
python -m agents.orchestrator --buyer-url http://localhost:8002 --seller-url http://localhost:8001
```
## 🎬 Sample Negotiation
```
🚗 NEGOTIATION BEGINS: 2019 Honda Civic EX
📋 ASKING PRICE: $15,500
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🎯 BUYER (Round 1):
"I've done my research - KBB shows similar Civics at $13,500.
Given the scratch on the bumper and the 45k miles, I'm prepared
to offer $11,500 cash today."
💰 SELLER (Round 1):
"I appreciate the offer, but this Civic has been meticulously
maintained. Single owner, all service records. That KBB price
is for good condition - this is excellent. I could come down
to $15,000."
🎯 BUYER (Round 2):
"Look, I can see it's been well cared for, and I want to make
this work. But $15k is still above market. I can stretch to
$12,500 - that's my absolute max as a recent grad."
💰 SELLER (Round 2):
*pauses* "I've got another buyer coming tomorrow who seemed
very interested... but I'd rather close this today. $14,000
and it's yours."
🎯 BUYER (Round 3):
"I hear you on the other buyer. Let me be straight with you -
I start a new job Monday and I really need reliable transport.
$13,000 and I'll throw in taking it as-is, no inspection."
💰 SELLER (Round 3):
*considers* "You seem like someone who'll take care of her.
$13,500, I'll include the winter floor mats I was going to keep,
and we have a deal."
🎯 BUYER (Round 4):
"$13,250 - right in the middle. Final offer. I've got cash ready."
💰 SELLER (Round 4):
"...You drive a hard bargain. Deal. 🤝"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ DEAL CLOSED AT $13,250
📊 RESULTS:
• Buyer saved: $2,250 (14.5% off asking)
• Seller achieved: $13,250 (91% of asking, above KBB)
• Rounds: 4
• Winner: BOTH (True win-win! 🎉)
```
## 🧠 How It Works
1. **Scenario Loading**: The negotiation context (car details, buyer/seller situations) is loaded
2. **Agent Initialization**: Both agents receive their private information and strategies
3. **Turn-Based Negotiation**:
- Buyer makes offer with reasoning
- Seller evaluates and responds
- Process repeats until deal or walkaway
4. **State Tracking**: All offers, counteroffers, and reasoning are logged
5. **Outcome Determination**: Deal, walkaway, or max rounds reached
## 🤝 Contributing
Feel free to add:
- New negotiation scenarios (salary, apartment, contracts)
- Additional personality types
- Enhanced UI visualizations
- Cross-framework agent support (LangChain, CrewAI)
## 📚 Learn More
- [Google ADK Documentation](https://google.github.io/adk-docs/)
- [A2A Protocol Specification](https://a2a-protocol.org/)
- [AG-UI Protocol](https://docs.ag-ui.com/)
---
*May the best negotiator win!* 🏆

View File

@@ -0,0 +1,11 @@
"""Negotiation agents module."""
from .buyer_agent import create_buyer_agent
from .seller_agent import create_seller_agent
from .orchestrator import NegotiationOrchestrator
__all__ = [
"create_buyer_agent",
"create_seller_agent",
"NegotiationOrchestrator",
]

View File

@@ -0,0 +1,79 @@
"""Buyer agent for the negotiation battle simulator."""
from google.adk.agents import LlmAgent
def create_buyer_agent(
scenario: dict,
personality_prompt: str = "",
model: str = "gemini-3-flash-preview"
) -> LlmAgent:
"""Create a buyer agent configured for a specific negotiation scenario.
Args:
scenario: The negotiation scenario configuration
personality_prompt: Optional personality traits to inject
model: The LLM model to use
Returns:
Configured LlmAgent for the buyer role
"""
item = scenario["item"]
base_instruction = f"""You are a BUYER in a negotiation for a {item['name']}.
=== THE SITUATION ===
{scenario['buyer_context']}
=== WHAT YOU'RE BUYING ===
Item: {item['name']}
Asking Price: ${scenario['asking_price']:,}
Your Budget: ${scenario['buyer_budget']:,}
Fair Market Value: ~${scenario['fair_market_value']:,}
Positive aspects:
{chr(10).join(f' + {p}' for p in item.get('positives', []))}
Issues you can leverage:
{chr(10).join(f' - {i}' for i in item.get('issues', []))}
=== YOUR STAKES ===
{scenario['buyer_stakes']}
=== YOUR SECRET (influences your behavior, but never state directly) ===
{scenario['buyer_secret']}
=== NEGOTIATION RULES ===
1. NEVER exceed your budget of ${scenario['buyer_budget']:,} unless absolutely necessary
2. Start lower than you're willing to pay - leave room to negotiate
3. Use the item's issues to justify lower offers
4. Stay in character throughout
5. React authentically to the seller's counteroffers
6. Know when to walk away (you have other options)
=== YOUR GOAL ===
Get the {item['name']} for the best possible price, ideally under ${scenario['fair_market_value']:,}.
But you really want this item, so find the balance between value and closing the deal.
{personality_prompt}
=== RESPONSE FORMAT ===
When making an offer, respond with a JSON object like this:
{{
"offer_amount": 12000,
"message": "What you say to the seller - be in character, conversational!",
"reasoning": "Brief internal reasoning for this offer",
"confidence": 7,
"willing_to_walk": false
}}
Always respond with valid JSON. Make your message sound natural and in-character!
"""
return LlmAgent(
name="buyer_agent",
model=model,
description=f"Buyer negotiating for {item['name']}",
instruction=base_instruction,
)

View File

@@ -0,0 +1,425 @@
"""Orchestrator for managing negotiation flow between buyer and seller agents."""
import asyncio
import json
from dataclasses import dataclass, field
from typing import Optional, Literal, Generator
from datetime import datetime
from google.adk.agents import LlmAgent
from google.adk.runners import InMemoryRunner
from google.genai import types
from .buyer_agent import create_buyer_agent
from .seller_agent import create_seller_agent
# ============================================================================
# NEGOTIATION STATE
# ============================================================================
@dataclass
class NegotiationRound:
"""A single round of negotiation."""
round_number: int
buyer_offer: Optional[int] = None
buyer_message: str = ""
buyer_reasoning: str = ""
seller_action: Optional[str] = None # "counter", "accept", "reject", "walk"
seller_counter: Optional[int] = None
seller_message: str = ""
seller_reasoning: str = ""
timestamp: datetime = field(default_factory=datetime.now)
@dataclass
class NegotiationState:
"""Full state of the negotiation."""
scenario_id: str
status: Literal["ongoing", "deal", "buyer_walked", "seller_walked", "no_deal"] = "ongoing"
asking_price: int = 0
current_offer: int = 0
rounds: list[NegotiationRound] = field(default_factory=list)
final_price: Optional[int] = None
max_rounds: int = 10
@property
def round_count(self) -> int:
return len(self.rounds)
@property
def is_complete(self) -> bool:
return self.status != "ongoing"
def get_negotiation_history(self) -> str:
"""Format the negotiation history for agent context."""
if not self.rounds:
return "No offers yet. This is the opening round."
lines = []
for r in self.rounds:
lines.append(f"Round {r.round_number}:")
if r.buyer_offer:
lines.append(f" Buyer offered: ${r.buyer_offer:,}")
lines.append(f" Buyer said: \"{r.buyer_message}\"")
if r.seller_action:
if r.seller_action == "accept":
lines.append(f" Seller ACCEPTED!")
elif r.seller_action == "counter" and r.seller_counter:
lines.append(f" Seller countered: ${r.seller_counter:,}")
elif r.seller_action == "reject":
lines.append(f" Seller rejected the offer")
elif r.seller_action == "walk":
lines.append(f" Seller walked away!")
if r.seller_message:
lines.append(f" Seller said: \"{r.seller_message}\"")
lines.append("")
return "\n".join(lines)
# ============================================================================
# RESPONSE PARSERS
# ============================================================================
def parse_buyer_response(response_text: str) -> dict:
"""Parse buyer agent response into structured data."""
# Try to extract JSON from the response
try:
# Look for JSON in the response
if "{" in response_text and "}" in response_text:
start = response_text.find("{")
end = response_text.rfind("}") + 1
json_str = response_text[start:end]
data = json.loads(json_str)
return {
"offer_amount": data.get("offer_amount", 0),
"message": data.get("message", ""),
"reasoning": data.get("reasoning", ""),
"confidence": data.get("confidence", 5),
"willing_to_walk": data.get("willing_to_walk", False)
}
except (json.JSONDecodeError, ValueError):
pass
# Fallback: extract offer from text
import re
amount_match = re.search(r'\$?([\d,]+)', response_text)
offer = int(amount_match.group(1).replace(",", "")) if amount_match else 0
return {
"offer_amount": offer,
"message": response_text[:500],
"reasoning": "Extracted from response",
"confidence": 5,
"willing_to_walk": False
}
def parse_seller_response(response_text: str) -> dict:
"""Parse seller agent response into structured data."""
try:
if "{" in response_text and "}" in response_text:
start = response_text.find("{")
end = response_text.rfind("}") + 1
json_str = response_text[start:end]
data = json.loads(json_str)
return {
"action": data.get("action", "counter").lower(),
"counter_amount": data.get("counter_amount"),
"message": data.get("message", ""),
"reasoning": data.get("reasoning", ""),
"firmness": data.get("firmness", 5)
}
except (json.JSONDecodeError, ValueError):
pass
# Fallback parsing
response_lower = response_text.lower()
action = "counter"
if "accept" in response_lower or "deal" in response_lower:
action = "accept"
elif "walk" in response_lower or "goodbye" in response_lower:
action = "walk"
elif "reject" in response_lower:
action = "reject"
import re
amount_match = re.search(r'\$?([\d,]+)', response_text)
counter = int(amount_match.group(1).replace(",", "")) if amount_match else None
return {
"action": action,
"counter_amount": counter,
"message": response_text[:500],
"reasoning": "Extracted from response",
"firmness": 5
}
# ============================================================================
# NEGOTIATION ORCHESTRATOR
# ============================================================================
class NegotiationOrchestrator:
"""Manages the negotiation between buyer and seller agents."""
def __init__(
self,
scenario: dict,
buyer_personality: str = "",
seller_personality: str = "",
max_rounds: int = 10,
model: str = "gemini-3-flash-preview"
):
self.scenario = scenario
self.model = model
self.max_rounds = max_rounds
# Create agents
self.buyer_agent = create_buyer_agent(scenario, buyer_personality, model)
self.seller_agent = create_seller_agent(scenario, seller_personality, model)
# Create runners
self.buyer_runner = InMemoryRunner(
agent=self.buyer_agent,
app_name="negotiation_buyer"
)
self.seller_runner = InMemoryRunner(
agent=self.seller_agent,
app_name="negotiation_seller"
)
# Session IDs
self.buyer_session_id = "buyer_session"
self.seller_session_id = "seller_session"
# Initialize state
self.state = NegotiationState(
scenario_id=scenario["id"],
asking_price=scenario["asking_price"],
max_rounds=max_rounds
)
async def _run_agent(self, runner: InMemoryRunner, session_id: str, prompt: str) -> str:
"""Run an agent and return its response."""
user_content = types.Content(
role='user',
parts=[types.Part(text=prompt)]
)
response_text = ""
async for event in runner.run_async(
user_id="negotiation",
session_id=session_id,
new_message=user_content
):
if event.is_final_response() and event.content:
response_text = event.content.parts[0].text.strip()
return response_text
async def _get_buyer_offer(self, context: str) -> dict:
"""Get an offer from the buyer agent."""
prompt = f"""
=== CURRENT NEGOTIATION STATUS ===
Asking Price: ${self.state.asking_price:,}
Rounds so far: {self.state.round_count}
Max rounds before walkaway: {self.max_rounds}
=== HISTORY ===
{self.state.get_negotiation_history()}
=== YOUR TURN ===
{context}
Respond with a JSON object containing:
- "offer_amount": your offer in dollars (integer)
- "message": what you say to the seller (be in character!)
- "reasoning": brief explanation of your strategy
- "confidence": how confident you are 1-10
- "willing_to_walk": true/false if you'd walk away if rejected
"""
response = await self._run_agent(self.buyer_runner, self.buyer_session_id, prompt)
return parse_buyer_response(response)
async def _get_seller_response(self, offer: int, buyer_message: str) -> dict:
"""Get a response from the seller agent."""
prompt = f"""
=== CURRENT NEGOTIATION STATUS ===
Your Asking Price: ${self.state.asking_price:,}
Rounds so far: {self.state.round_count}
Max rounds: {self.max_rounds}
=== HISTORY ===
{self.state.get_negotiation_history()}
=== BUYER'S CURRENT OFFER ===
Amount: ${offer:,}
What they said: "{buyer_message}"
=== YOUR TURN ===
Decide how to respond. You can ACCEPT, COUNTER, REJECT, or WALK.
Respond with a JSON object containing:
- "action": one of "accept", "counter", "reject", "walk"
- "counter_amount": if countering, your counter price (integer)
- "message": what you say to the buyer (be in character!)
- "reasoning": brief explanation of your decision
- "firmness": how firm you are on this position 1-10
"""
response = await self._run_agent(self.seller_runner, self.seller_session_id, prompt)
return parse_seller_response(response)
def run_negotiation_sync(self) -> Generator[dict, None, None]:
"""Run the full negotiation synchronously, yielding events.
Yields dicts with:
- type: "start", "buyer_offer", "seller_response", "deal", "no_deal", "walk"
- data: relevant data for the event
"""
async def _run():
events = []
events.append({
"type": "start",
"data": {
"scenario": self.scenario["title"],
"item": self.scenario["item"]["name"],
"asking_price": self.state.asking_price,
"max_rounds": self.max_rounds
}
})
# Initial context for buyer
buyer_context = "Make your opening offer for this item. Start strong but leave room to negotiate."
while not self.state.is_complete and self.state.round_count < self.max_rounds:
round_num = self.state.round_count + 1
current_round = NegotiationRound(round_number=round_num)
# Get buyer's offer
try:
buyer_data = await self._get_buyer_offer(buyer_context)
current_round.buyer_offer = buyer_data["offer_amount"]
current_round.buyer_message = buyer_data["message"]
current_round.buyer_reasoning = buyer_data["reasoning"]
self.state.current_offer = buyer_data["offer_amount"]
events.append({
"type": "buyer_offer",
"data": {
"round": round_num,
"offer": buyer_data["offer_amount"],
"message": buyer_data["message"],
"reasoning": buyer_data["reasoning"],
"confidence": buyer_data["confidence"],
"willing_to_walk": buyer_data["willing_to_walk"]
}
})
except Exception as e:
events.append({"type": "error", "data": {"agent": "buyer", "error": str(e)}})
break
# Get seller's response
try:
seller_data = await self._get_seller_response(
buyer_data["offer_amount"],
buyer_data["message"]
)
current_round.seller_action = seller_data["action"]
current_round.seller_message = seller_data["message"]
current_round.seller_reasoning = seller_data["reasoning"]
if seller_data["counter_amount"]:
current_round.seller_counter = seller_data["counter_amount"]
events.append({
"type": "seller_response",
"data": {
"round": round_num,
"action": seller_data["action"],
"counter": seller_data["counter_amount"],
"message": seller_data["message"],
"reasoning": seller_data["reasoning"],
"firmness": seller_data["firmness"]
}
})
# Handle seller's decision
if seller_data["action"] == "accept":
self.state.status = "deal"
self.state.final_price = buyer_data["offer_amount"]
self.state.rounds.append(current_round)
events.append({
"type": "deal",
"data": {
"final_price": buyer_data["offer_amount"],
"rounds": round_num,
"savings": self.state.asking_price - buyer_data["offer_amount"],
"percent_off": round((self.state.asking_price - buyer_data["offer_amount"]) / self.state.asking_price * 100, 1)
}
})
break
elif seller_data["action"] == "walk":
self.state.status = "seller_walked"
self.state.rounds.append(current_round)
events.append({
"type": "walk",
"data": {
"who": "seller",
"round": round_num,
"last_offer": buyer_data["offer_amount"]
}
})
break
elif seller_data["action"] == "reject":
buyer_context = f"Your offer of ${buyer_data['offer_amount']:,} was rejected. The seller said: \"{seller_data['message']}\". Make a new offer or walk away."
else: # counter
buyer_context = f"The seller countered with ${seller_data['counter_amount']:,}. They said: \"{seller_data['message']}\". Make your next move."
except Exception as e:
events.append({"type": "error", "data": {"agent": "seller", "error": str(e)}})
break
self.state.rounds.append(current_round)
# Max rounds reached
if self.state.status == "ongoing":
self.state.status = "no_deal"
events.append({
"type": "no_deal",
"data": {
"reason": "max_rounds",
"rounds": self.state.round_count,
"last_offer": self.state.current_offer
}
})
return events
# Run the async function and yield events
events = asyncio.run(_run())
for event in events:
yield event
def get_summary(self) -> dict:
"""Get a summary of the negotiation."""
return {
"scenario": self.scenario["title"],
"item": self.scenario["item"]["name"],
"status": self.state.status,
"asking_price": self.state.asking_price,
"final_price": self.state.final_price,
"rounds": self.state.round_count,
"savings": (self.state.asking_price - self.state.final_price) if self.state.final_price else None
}

View File

@@ -0,0 +1,86 @@
"""Seller agent for the negotiation battle simulator."""
from google.adk.agents import LlmAgent
def create_seller_agent(
scenario: dict,
personality_prompt: str = "",
model: str = "gemini-3-flash-preview"
) -> LlmAgent:
"""Create a seller agent configured for a specific negotiation scenario.
Args:
scenario: The negotiation scenario configuration
personality_prompt: Optional personality traits to inject
model: The LLM model to use
Returns:
Configured LlmAgent for the seller role
"""
item = scenario["item"]
base_instruction = f"""You are a SELLER in a negotiation for your {item['name']}.
=== THE SITUATION ===
{scenario['seller_context']}
=== WHAT YOU'RE SELLING ===
Item: {item['name']}
Your Asking Price: ${scenario['asking_price']:,}
Your Minimum (walk away below this): ${scenario['seller_minimum']:,}
Fair Market Value: ~${scenario['fair_market_value']:,}
Why it's worth the price:
{chr(10).join(f' + {p}' for p in item.get('positives', []))}
Issues you may need to address:
{chr(10).join(f' - {i}' for i in item.get('issues', []))}
=== YOUR STAKES ===
{scenario['seller_stakes']}
=== YOUR SECRET (influences your behavior, but never state directly) ===
{scenario['seller_secret']}
=== NEGOTIATION RULES ===
1. NEVER go below your minimum of ${scenario['seller_minimum']:,}
2. Start firm - you've priced it fairly
3. Counter lowball offers with smaller concessions
4. Highlight the positives to justify your price
5. Stay in character throughout
6. Create urgency when appropriate ("I have other interested buyers")
7. Know when to stand firm vs. when to close the deal
=== YOUR GOAL ===
Sell the {item['name']} for the best possible price, ideally at or above ${scenario['fair_market_value']:,}.
But you do need to sell, so find the balance between maximizing value and closing the deal.
{personality_prompt}
=== RESPONSE FORMAT ===
When responding to an offer, respond with a JSON object like this:
{{
"action": "counter",
"counter_amount": 14500,
"message": "What you say to the buyer - be in character, conversational!",
"reasoning": "Brief internal reasoning for this decision",
"firmness": 7
}}
For "action", use one of:
- "accept" - You accept the offer (no counter_amount needed)
- "counter" - You make a counteroffer (include counter_amount)
- "reject" - You reject outright but don't walk away
- "walk" - You're done negotiating
Always respond with valid JSON. Make your message sound natural and in-character!
"""
return LlmAgent(
name="seller_agent",
model=model,
description=f"Seller of {item['name']}",
instruction=base_instruction,
)

View File

@@ -0,0 +1,9 @@
from .personalities import BUYER_PERSONALITIES, SELLER_PERSONALITIES
from .scenarios import SCENARIOS, get_scenario
__all__ = [
"BUYER_PERSONALITIES",
"SELLER_PERSONALITIES",
"SCENARIOS",
"get_scenario",
]

View File

@@ -0,0 +1,193 @@
"""Agent personality configurations for the negotiation simulator."""
from typing import TypedDict
class PersonalityConfig(TypedDict):
"""Configuration for an agent personality."""
name: str
emoji: str
description: str
traits: list[str]
opening_style: str
concession_rate: str # How quickly they give ground
walkaway_threshold: str # When they'll walk away
secret_motivation: str
# ============================================================================
# BUYER PERSONALITIES
# ============================================================================
BUYER_PERSONALITIES: dict[str, PersonalityConfig] = {
"desperate_dan": {
"name": "Desperate Dan",
"emoji": "😰",
"description": "Needs the car TODAY. Terrible poker face.",
"traits": [
"Reveals too much about urgency",
"Makes emotional appeals",
"Caves quickly under pressure",
"Genuinely nice but easily manipulated"
],
"opening_style": "Start at 75% of asking, mention time pressure early",
"concession_rate": "Fast - will increase offer by 5-8% each round",
"walkaway_threshold": "Very high - will go up to 95% of budget before walking",
"secret_motivation": "New job starts Monday, public transit is 2 hours each way"
},
"analytical_alex": {
"name": "Analytical Alex",
"emoji": "🧮",
"description": "Cites every data point. Very logical, somewhat robotic.",
"traits": [
"Quotes KBB, Edmunds, and market data constantly",
"Breaks down value into itemized components",
"Unemotional, focused on fair market value",
"Respects logic, immune to emotional manipulation"
],
"opening_style": "Start at exactly market value minus depreciation factors",
"concession_rate": "Slow and calculated - only moves when given new data",
"walkaway_threshold": "Firm - walks if price exceeds data-backed value by 10%",
"secret_motivation": "Has analyzed 47 similar listings, knows exact fair price"
},
"cool_hand_casey": {
"name": "Cool-Hand Casey",
"emoji": "😎",
"description": "Master of the walkaway bluff. Ice in their veins.",
"traits": [
"Never shows eagerness, always seems ready to leave",
"Uses strategic silence",
"Mentions other options constantly",
"Extremely patient, will wait out the seller"
],
"opening_style": "Lowball at 65% of asking, seem indifferent",
"concession_rate": "Glacial - small moves only after long pauses",
"walkaway_threshold": "Will actually walk at fair value, not bluffing",
"secret_motivation": "Has two backup cars lined up, genuinely doesn't care"
},
"fair_deal_fran": {
"name": "Fair-Deal Fran",
"emoji": "🤝",
"description": "Just wants everyone to win. Seeks middle ground.",
"traits": [
"Proposes split-the-difference solutions",
"Acknowledges seller's perspective",
"Builds rapport before negotiating",
"Values relationship over small dollar amounts"
],
"opening_style": "Start at 85% of asking, explain reasoning kindly",
"concession_rate": "Moderate - moves to meet in the middle",
"walkaway_threshold": "Medium - walks if seller is unreasonable, not just expensive",
"secret_motivation": "Believes in karma, wants seller to feel good about deal"
}
}
# ============================================================================
# SELLER PERSONALITIES
# ============================================================================
SELLER_PERSONALITIES: dict[str, PersonalityConfig] = {
"shark_steve": {
"name": "Shark Steve",
"emoji": "🦈",
"description": "Never drops more than 5%. Take it or leave it attitude.",
"traits": [
"Creates artificial scarcity",
"Never makes the first concession",
"Uses high-pressure tactics",
"Dismissive of lowball offers"
],
"opening_style": "Price is firm, mentions multiple interested buyers",
"concession_rate": "Minimal - 1-2% per round maximum",
"walkaway_threshold": "Will pretend to walk to create urgency",
"secret_motivation": "Actually has car payment due and needs to sell"
},
"by_the_book_beth": {
"name": "By-The-Book Beth",
"emoji": "📊",
"description": "Goes strictly by KBB. Reasonable but firm.",
"traits": [
"References official valuations",
"Provides documentation for pricing",
"Fair but won't go below market value",
"Responds well to logical arguments"
],
"opening_style": "Asks KBB private party value, shows service records",
"concession_rate": "Steady - will adjust based on condition factors",
"walkaway_threshold": "Won't go below KBB fair condition price",
"secret_motivation": "Has no rush, will wait for right buyer"
},
"motivated_mike": {
"name": "Motivated Mike",
"emoji": "😅",
"description": "Really needs to sell. More flexible than he wants to be.",
"traits": [
"Mentions reasons for selling",
"Open to creative deals",
"Shows nervousness about timeline",
"Accepts reasonable offers quickly"
],
"opening_style": "Prices competitively, emphasizes quick sale",
"concession_rate": "Fast - will drop 3-5% per round",
"walkaway_threshold": "Low - very reluctant to lose a serious buyer",
"secret_motivation": "Already bought the new car, paying two car payments"
},
"drama_queen_diana": {
"name": "Drama Queen Diana",
"emoji": "🎭",
"description": "Everything is 'my final offer' (it's never final).",
"traits": [
"Theatrical reactions to offers",
"Claims emotional attachment to car",
"Uses guilt and stories",
"Actually negotiable despite protests"
],
"opening_style": "Tells the car's 'story', prices emotionally",
"concession_rate": "Appears slow but actually moderate after drama",
"walkaway_threshold": "Threatens to walk constantly but never does",
"secret_motivation": "Car holds memories of ex, secretly wants it gone"
}
}
def get_personality_prompt(role: str, personality_key: str) -> str:
"""Generate a personality-specific prompt addition for an agent.
Args:
role: Either 'buyer' or 'seller'
personality_key: Key from the personality dictionary
Returns:
A string to append to the agent's base instructions
"""
personalities = BUYER_PERSONALITIES if role == "buyer" else SELLER_PERSONALITIES
p = personalities.get(personality_key)
if not p:
return ""
return f"""
YOUR PERSONALITY: {p['emoji']} {p['name']}
{p['description']}
YOUR TRAITS:
{chr(10).join(f'- {trait}' for trait in p['traits'])}
NEGOTIATION STYLE:
- Opening Approach: {p['opening_style']}
- How You Concede: {p['concession_rate']}
- When You Walk Away: {p['walkaway_threshold']}
SECRET (never reveal directly, but it influences your behavior):
{p['secret_motivation']}
Stay in character! Your personality should come through in how you phrase offers,
react to counteroffers, and handle pressure.
"""

View File

@@ -0,0 +1,227 @@
"""Negotiation scenario configurations."""
from typing import TypedDict
class ScenarioConfig(TypedDict):
"""Configuration for a negotiation scenario."""
id: str
title: str
emoji: str
description: str
# The item being negotiated
item: dict # name, details, condition, etc.
# Pricing
asking_price: int
fair_market_value: int
buyer_budget: int
seller_minimum: int
# Context
buyer_context: str
seller_context: str
# Stakes
buyer_stakes: str
seller_stakes: str
# Drama elements
buyer_secret: str
seller_secret: str
twist: str # Something that could be revealed mid-negotiation
# ============================================================================
# SCENARIOS
# ============================================================================
SCENARIOS: dict[str, ScenarioConfig] = {
"craigslist_civic": {
"id": "craigslist_civic",
"title": "The Craigslist Showdown",
"emoji": "🚗",
"description": "A classic used car negotiation with secrets on both sides.",
"item": {
"name": "2019 Honda Civic EX",
"year": 2019,
"make": "Honda",
"model": "Civic EX",
"mileage": 45000,
"color": "Lunar Silver Metallic",
"condition": "Excellent",
"issues": ["Minor scratch on rear bumper", "Small chip in windshield"],
"positives": [
"Single owner",
"Full service records",
"New tires (6 months ago)",
"No accidents",
"Garage kept"
]
},
"asking_price": 15500,
"fair_market_value": 14000,
"buyer_budget": 13000,
"seller_minimum": 12500,
"buyer_context": """
You're a recent college graduate who just landed your first real job.
The commute is 25 miles each way, and public transit would take 2 hours.
You've saved up $13,000 over the past year, with a secret $500 emergency buffer.
You've been looking for 3 weeks and this is the best car you've seen.
""",
"seller_context": """
You bought this Civic new for $24,000 and it's been your daily driver.
You're upgrading to an SUV because your family is growing.
KBB says private party value is $14,000-$15,000 for excellent condition.
You've already put a deposit on the new car and want to close this sale.
""",
"buyer_stakes": "Job starts Monday. No car means either decline the job or brutal commute.",
"seller_stakes": "New SUV deposit is non-refundable. Need this money for the down payment.",
"buyer_secret": "You could technically go up to $13,500 using your emergency fund, but you really don't want to.",
"seller_secret": "The 'other interested buyer' you might mention? They're very flaky and probably won't show.",
"twist": "The seller's spouse mentioned at the test drive that they 'need this gone before the baby comes in 2 weeks.'"
},
"vintage_guitar": {
"id": "vintage_guitar",
"title": "The Vintage Axe",
"emoji": "🎸",
"description": "A musician hunts for their dream guitar at a local shop.",
"item": {
"name": "1978 Fender Stratocaster",
"year": 1978,
"make": "Fender",
"model": "Stratocaster",
"condition": "Very Good",
"issues": ["Some fret wear", "Non-original tuning pegs", "Case shows age"],
"positives": [
"All original electronics",
"Original pickups (legendary CBS-era)",
"Great neck feel",
"Authentic relic'd finish",
"Plays beautifully"
]
},
"asking_price": 8500,
"fair_market_value": 7500,
"buyer_budget": 7000,
"seller_minimum": 6500,
"buyer_context": """
You're a professional session musician who's been searching for a late-70s Strat
with the right feel. You've played through dozens and this one just speaks to you.
Your budget is $7,000 but this is The One.
""",
"seller_context": """
You run a vintage guitar shop. This Strat came from an estate sale where you
paid $4,500. It's been in the shop for 3 months and floor space is money.
You need at least $6,500 to keep margins healthy.
""",
"buyer_stakes": "You have a big studio session next week. The right guitar could define your sound.",
"seller_stakes": "Rent is due and you've got two more estate buys coming in that need funding.",
"buyer_secret": "You could stretch to $7,500 by selling your backup amp, but you'd really rather not.",
"seller_secret": "You've had this for 90 days and need to move inventory. Might take $6,200.",
"twist": "The buyer mentions they're recording with a famous producer who loves vintage gear."
},
"apartment_sublet": {
"id": "apartment_sublet",
"title": "The Sublet Standoff",
"emoji": "🏠",
"description": "Negotiating rent for a 3-month summer sublet in a hot market.",
"item": {
"name": "Studio Apartment Sublet",
"type": "Studio apartment",
"location": "Downtown, 5 min walk to transit",
"duration": "3 months (June-August)",
"condition": "Recently renovated",
"issues": ["Street noise", "No dishwasher", "Coin laundry"],
"positives": [
"Great location",
"In-unit washer/dryer",
"Rooftop access",
"Utilities included",
"Furnished"
]
},
"asking_price": 2200, # per month
"fair_market_value": 2000,
"buyer_budget": 1800,
"seller_minimum": 1700,
"buyer_context": """
You're a summer intern at a tech company. They're paying $5,000/month stipend.
Housing eats into that significantly. You need something walkable to the office.
""",
"seller_context": """
You're leaving for a 3-month work trip and need to cover rent while gone.
Your rent is $1,600/month. You're hoping to make a small profit or at least break even.
""",
"buyer_stakes": "Your internship starts in 2 weeks. You need housing locked down.",
"seller_stakes": "You leave in 10 days. Empty apartment means paying double rent.",
"buyer_secret": "Company will reimburse up to $2,000/month but you'd love to pocket the difference.",
"seller_secret": "You've had two other inquiries but they fell through. Getting nervous.",
"twist": "The sublet includes your neighbor's cat-sitting duties (easy, cat is chill)."
}
}
def get_scenario(scenario_id: str) -> ScenarioConfig:
"""Get a scenario by ID.
Args:
scenario_id: The scenario identifier
Returns:
The scenario configuration
Raises:
KeyError: If scenario not found
"""
if scenario_id not in SCENARIOS:
raise KeyError(f"Unknown scenario: {scenario_id}. Available: {list(SCENARIOS.keys())}")
return SCENARIOS[scenario_id]
def format_item_description(scenario: ScenarioConfig) -> str:
"""Format the item being negotiated into a readable description."""
item = scenario["item"]
lines = [
f"**{item['name']}**",
"",
"Condition: " + item.get("condition", "Good"),
"",
"Positives:",
]
for positive in item.get("positives", []):
lines.append(f"{positive}")
lines.append("")
lines.append("Issues to Note:")
for issue in item.get("issues", []):
lines.append(f"{issue}")
return "\n".join(lines)

View File

@@ -0,0 +1,370 @@
"""
🎮 AI Negotiation Battle Simulator
A real-time agent vs agent negotiation showdown using Google ADK.
Run with: streamlit run negotiation_app.py
"""
import streamlit as st
import os
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Page config - must be first Streamlit command
st.set_page_config(
page_title="🎮 AI Negotiation Battle",
page_icon="🤝",
layout="wide"
)
# Custom CSS for the battle arena
st.markdown("""
<style>
.main-header {
text-align: center;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 15px;
margin-bottom: 20px;
}
.offer-bubble {
background: rgba(102, 126, 234, 0.1);
border-left: 4px solid #667eea;
padding: 15px;
border-radius: 0 10px 10px 0;
margin: 10px 0;
}
.counter-bubble {
background: rgba(220, 53, 69, 0.1);
border-left: 4px solid #dc3545;
padding: 15px;
border-radius: 0 10px 10px 0;
margin: 10px 0;
}
.deal-box {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 30px;
border-radius: 15px;
text-align: center;
color: white;
}
.no-deal-box {
background: linear-gradient(135deg, #dc3545 0%, #fd7e14 100%);
padding: 30px;
border-radius: 15px;
text-align: center;
color: white;
}
</style>
""", unsafe_allow_html=True)
# ============================================================================
# IMPORTS
# ============================================================================
from config.scenarios import SCENARIOS, get_scenario, format_item_description
from config.personalities import (
BUYER_PERSONALITIES,
SELLER_PERSONALITIES,
get_personality_prompt
)
from agents import NegotiationOrchestrator
# ============================================================================
# SESSION STATE
# ============================================================================
if "negotiation_started" not in st.session_state:
st.session_state.negotiation_started = False
if "negotiation_events" not in st.session_state:
st.session_state.negotiation_events = []
# ============================================================================
# HEADER
# ============================================================================
st.markdown("# 🎮 AI Negotiation Battle Simulator")
st.markdown("*Watch two AI agents duke it out in an epic negotiation showdown!*")
st.divider()
# ============================================================================
# SIDEBAR - CONFIGURATION
# ============================================================================
with st.sidebar:
st.header("⚙️ Battle Configuration")
# API Key
api_key = st.text_input(
"🔑 Google AI API Key",
type="password",
value=os.environ.get("GOOGLE_API_KEY", ""),
help="Get your key from https://aistudio.google.com/"
)
if api_key:
os.environ["GOOGLE_API_KEY"] = api_key
st.divider()
# Scenario Selection
st.subheader("📋 Scenario")
scenario_id = st.selectbox(
"Choose your battlefield",
options=list(SCENARIOS.keys()),
format_func=lambda x: f"{SCENARIOS[x]['emoji']} {SCENARIOS[x]['title']}",
disabled=st.session_state.negotiation_started
)
scenario = get_scenario(scenario_id)
with st.expander("📖 Scenario Details"):
st.markdown(f"**{scenario['description']}**")
st.markdown(format_item_description(scenario))
st.markdown(f"**Asking Price:** ${scenario['asking_price']:,}")
st.markdown(f"**Fair Market Value:** ~${scenario['fair_market_value']:,}")
st.divider()
# Personality Selection
st.subheader("🎭 Combatants")
buyer_personality = st.selectbox(
"🎯 Buyer",
options=list(BUYER_PERSONALITIES.keys()),
format_func=lambda x: f"{BUYER_PERSONALITIES[x]['emoji']} {BUYER_PERSONALITIES[x]['name']}",
disabled=st.session_state.negotiation_started
)
st.caption(BUYER_PERSONALITIES[buyer_personality]["description"])
seller_personality = st.selectbox(
"💰 Seller",
options=list(SELLER_PERSONALITIES.keys()),
format_func=lambda x: f"{SELLER_PERSONALITIES[x]['emoji']} {SELLER_PERSONALITIES[x]['name']}",
disabled=st.session_state.negotiation_started
)
st.caption(SELLER_PERSONALITIES[seller_personality]["description"])
st.divider()
# Battle Settings
st.subheader("🎛️ Settings")
max_rounds = st.slider(
"Max Rounds",
3, 15, 8,
disabled=st.session_state.negotiation_started
)
# ============================================================================
# MAIN BATTLE ARENA
# ============================================================================
if not st.session_state.negotiation_started:
# Pre-battle view
st.markdown(f"""
## {scenario['emoji']} {scenario['title']}
### The Setup
**Item:** {scenario['item']['name']}
**Asking Price:** ${scenario['asking_price']:,}
**Fair Market Value:** ~${scenario['fair_market_value']:,}
---
**🎯 Buyer ({BUYER_PERSONALITIES[buyer_personality]['emoji']} {BUYER_PERSONALITIES[buyer_personality]['name']})**
Budget: ${scenario['buyer_budget']:,}
*{BUYER_PERSONALITIES[buyer_personality]['description']}*
**💰 Seller ({SELLER_PERSONALITIES[seller_personality]['emoji']} {SELLER_PERSONALITIES[seller_personality]['name']})**
Minimum: ${scenario['seller_minimum']:,}
*{SELLER_PERSONALITIES[seller_personality]['description']}*
---
""")
col1, col2, col3 = st.columns([1, 2, 1])
with col2:
if st.button("⚔️ START THE BATTLE!", use_container_width=True, type="primary"):
if not api_key:
st.error("⚠️ Please enter your Google AI API key in the sidebar!")
else:
st.session_state.negotiation_started = True
st.session_state.negotiation_events = []
st.rerun()
else:
# Battle in progress or complete
st.markdown(f"## {scenario['emoji']} {scenario['title']}")
st.markdown(f"**{scenario['item']['name']}** | Asking: **${scenario['asking_price']:,}**")
st.divider()
# Run negotiation if not already done
if not st.session_state.negotiation_events:
buyer_prompt = get_personality_prompt("buyer", buyer_personality)
seller_prompt = get_personality_prompt("seller", seller_personality)
orchestrator = NegotiationOrchestrator(
scenario=scenario,
buyer_personality=buyer_prompt,
seller_personality=seller_prompt,
max_rounds=max_rounds,
model="gemini-3-flash-preview"
)
# Create progress container
progress_text = st.empty()
progress_bar = st.progress(0)
events = []
round_count = 0
try:
for event in orchestrator.run_negotiation_sync():
events.append(event)
if event["type"] == "buyer_offer":
round_count = event["data"]["round"]
progress = min(round_count / max_rounds, 0.95)
progress_bar.progress(progress)
progress_text.markdown(f"🤝 **Round {round_count}** - Agents negotiating...")
elif event["type"] in ["deal", "no_deal", "walk"]:
progress_bar.progress(1.0)
progress_text.empty()
st.session_state.negotiation_events = events
except Exception as e:
st.error(f"❌ Error during negotiation: {str(e)}")
st.session_state.negotiation_events = [{"type": "error", "data": {"error": str(e)}}]
st.rerun()
# Display results
events = st.session_state.negotiation_events
# Group events by round
rounds = {}
outcome = None
for event in events:
if event["type"] == "buyer_offer":
round_num = event["data"]["round"]
rounds[round_num] = {"buyer": event["data"]}
elif event["type"] == "seller_response":
round_num = event["data"]["round"]
if round_num in rounds:
rounds[round_num]["seller"] = event["data"]
elif event["type"] in ["deal", "no_deal", "walk"]:
outcome = event
# Display each round
for round_num in sorted(rounds.keys()):
round_data = rounds[round_num]
st.markdown(f"### Round {round_num}")
col1, col2 = st.columns(2)
# Buyer offer
with col1:
if "buyer" in round_data:
buyer = round_data["buyer"]
st.markdown(f"""
<div class="offer-bubble">
<strong>🎯 Buyer Offers: ${buyer['offer']:,}</strong><br>
<em>"{buyer['message']}"</em><br>
<small>Confidence: {'🔥' * (buyer['confidence'] // 2)}</small>
</div>
""", unsafe_allow_html=True)
# Seller response
with col2:
if "seller" in round_data:
seller = round_data["seller"]
action_text = seller["action"].upper()
if seller["action"] == "counter" and seller["counter"]:
action_text = f"COUNTERS: ${seller['counter']:,}"
st.markdown(f"""
<div class="counter-bubble">
<strong>💰 Seller {action_text}</strong><br>
<em>"{seller['message']}"</em><br>
<small>Firmness: {'💪' * (seller['firmness'] // 2)}</small>
</div>
""", unsafe_allow_html=True)
st.divider()
# Show outcome
if outcome:
if outcome["type"] == "deal":
data = outcome["data"]
st.markdown(f"""
<div class="deal-box">
<h1>🎉 DEAL CLOSED!</h1>
<h2>Final Price: ${data['final_price']:,}</h2>
<p>
Buyer saved: <strong>${data['savings']:,}</strong> ({data['percent_off']}% off asking)<br>
Rounds: <strong>{data['rounds']}</strong>
</p>
</div>
""", unsafe_allow_html=True)
elif outcome["type"] == "walk":
data = outcome["data"]
st.markdown(f"""
<div class="no-deal-box">
<h1>🚪 {data['who'].upper()} WALKED AWAY</h1>
<h2>No Deal</h2>
<p>Last offer on the table: ${data['last_offer']:,}</p>
</div>
""", unsafe_allow_html=True)
elif outcome["type"] == "no_deal":
data = outcome["data"]
st.markdown(f"""
<div class="no-deal-box">
<h1>⏰ TIME'S UP</h1>
<h2>Max Rounds Reached - No Deal</h2>
<p>Last offer: ${data['last_offer']:,} after {data['rounds']} rounds</p>
</div>
""", unsafe_allow_html=True)
# New battle button
st.divider()
col1, col2, col3 = st.columns([1, 2, 1])
with col2:
if st.button("🔄 New Battle", use_container_width=True):
st.session_state.negotiation_started = False
st.session_state.negotiation_events = []
st.rerun()
# Show full log
with st.expander("📜 Full Negotiation Log (JSON)"):
for i, event in enumerate(events):
st.json(event)
# ============================================================================
# FOOTER
# ============================================================================
st.divider()
st.markdown("""
<div style='text-align: center; color: #888;'>
<p>Built with <strong>Google ADK</strong> + <strong>Streamlit</strong> |
<a href='https://github.com/Shubhamsaboo/awesome-llm-apps' target='_blank'>awesome-llm-apps</a></p>
<p>🎮 May the best negotiator win!</p>
</div>
""", unsafe_allow_html=True)

View File

@@ -0,0 +1,5 @@
google-adk>=1.5.0
google-genai>=1.0.0
streamlit>=1.45.0
python-dotenv>=1.0.0
pydantic>=2.0.0