mirror of
https://github.com/Shubhamsaboo/awesome-llm-apps.git
synced 2026-03-11 17:48:31 -05:00
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:
@@ -0,0 +1,2 @@
|
||||
# Get your API key from https://aistudio.google.com/
|
||||
GOOGLE_API_KEY=your_google_ai_studio_key_here
|
||||
@@ -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!* 🏆
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user