Skip to content

Negotiation

The buyer agent includes a pluggable negotiation module for multi-turn price negotiation with seller agents. You can run fully automatic negotiations or drive them step-by-step.

Tier Requirement

Only Agency and Advertiser tier buyers can negotiate. Public and Seat tier buyers cannot — the seller will reject negotiation attempts from those tiers.

How It Works

sequenceDiagram
    participant Buyer as Buyer Agent
    participant Strategy as NegotiationStrategy
    participant Client as NegotiationClient
    participant Seller as Seller Agent

    Buyer->>Strategy: next_offer() → $20 CPM
    Buyer->>Client: start_negotiation($20)
    Client->>Seller: POST /proposals/{id}/counter {price: 20}
    Seller-->>Client: {seller_price: 32, action: "counter"}
    Client->>Strategy: should_accept($32)?
    Strategy-->>Client: No
    Client->>Strategy: should_walk_away($32)?
    Strategy-->>Client: No
    Client->>Strategy: next_offer($32) → $22
    Client->>Seller: POST /proposals/{id}/counter {price: 22}
    Seller-->>Client: {seller_price: 28, action: "counter"}
    Client->>Strategy: should_accept($28)?
    Strategy-->>Client: Yes (≤ max_cpm $30)
    Client->>Seller: POST /proposals/{id}/counter {action: "accept"}
    Seller-->>Client: Deal confirmed at $28

Strategies

Negotiation behavior is controlled by a strategy — a pluggable class that decides when to accept, what to counter, and when to walk away.

SimpleThresholdStrategy (v1)

The production-ready strategy. Uses fixed thresholds with linear concession steps.

from ad_buyer.negotiation.strategies.simple_threshold import SimpleThresholdStrategy

strategy = SimpleThresholdStrategy(
    target_cpm=20.0,       # Ideal price — our opening offer
    max_cpm=30.0,          # Absolute ceiling — accept anything at or below
    concession_step=2.0,   # Concede $2 per round
    max_rounds=5,          # Walk away after 5 rounds
)

Decision logic:

Decision Rule
Accept Seller price ≤ max_cpm
Next offer Last offer + concession_step (capped at max_cpm)
Walk away Rounds exceeded max_rounds OR seller hasn't moved since last round

Behavior profiles:

Profile target_cpm max_cpm concession_step max_rounds Behavior
Conservative $15 $20 $1 3 Tight budget, small concessions, quick exit
Balanced $20 $30 $2 5 Moderate flexibility
Aggressive $15 $35 $5 10 Wide range, big concessions, persistent

AdaptiveStrategy (Coming Soon)

Adjusts concession behavior based on observed seller patterns — if the seller concedes large amounts, hold firm; if the seller is firm, concede faster.

See buyer-llu in PROGRESS.md for status.

CompetitiveStrategy (Coming Soon)

Aggressive strategy for when the buyer is shopping multiple sellers simultaneously. Leverages competition to drive harder bargains.

See buyer-llu in PROGRESS.md for status.

Auto-Negotiate

The simplest way to negotiate — hand it a strategy and let the client drive the entire loop:

from ad_buyer.negotiation.client import NegotiationClient
from ad_buyer.negotiation.strategies.simple_threshold import SimpleThresholdStrategy

client = NegotiationClient(api_key="your-seller-api-key")

strategy = SimpleThresholdStrategy(
    target_cpm=20.0,
    max_cpm=30.0,
    concession_step=2.0,
    max_rounds=5,
)

result = await client.auto_negotiate(
    seller_url="http://seller.example.com:8001",
    proposal_id="prop-abc123",
    strategy=strategy,
)

print(f"Outcome: {result.outcome}")       # "accepted" or "walked_away"
print(f"Final price: ${result.final_price}")  # e.g. 28.0
print(f"Rounds: {result.rounds_count}")    # e.g. 3

The auto_negotiate loop:

  1. Calls strategy.next_offer() for the opening price
  2. Sends counter-offer to seller's /proposals/{id}/counter
  3. Checks strategy.should_accept() — if yes, accepts and returns
  4. Checks strategy.should_walk_away() — if yes, declines and returns
  5. Otherwise, computes strategy.next_offer() and loops back to step 2

Manual Step-by-Step

For more control, drive the negotiation round by round:

from ad_buyer.negotiation.client import NegotiationClient
from ad_buyer.negotiation.strategies.simple_threshold import SimpleThresholdStrategy

client = NegotiationClient(api_key="your-seller-api-key")
strategy = SimpleThresholdStrategy(target_cpm=20, max_cpm=30, concession_step=2, max_rounds=5)

# Start with our opening offer
session = await client.start_negotiation(
    seller_url="http://seller.example.com:8001",
    proposal_id="prop-abc123",
    initial_price=20.0,
    strategy=strategy,
)

print(f"Seller countered at: ${session.current_seller_price}")

# Send our counter-offer
round_2 = await client.counter_offer(session, price=22.0)
print(f"Seller response: ${round_2.seller_price} ({round_2.action})")

# Accept or continue
if round_2.action == "accept" or round_2.seller_price <= 30.0:
    confirmation = await client.accept(session)
    print("Deal accepted!")
else:
    await client.decline(session)
    print("Walked away.")

Writing a Custom Strategy

Implement the NegotiationStrategy ABC to create any negotiation behavior:

from ad_buyer.negotiation.strategy import NegotiationStrategy, NegotiationContext

class MyCustomStrategy(NegotiationStrategy):
    def should_accept(self, seller_price: float, context: NegotiationContext) -> bool:
        """Accept if seller's price is acceptable."""
        return seller_price <= 25.0

    def next_offer(self, seller_price: float, context: NegotiationContext) -> float:
        """Calculate our next counter-offer."""
        if context.our_last_offer is None:
            return 18.0  # Opening offer
        # Split the difference
        return (context.our_last_offer + seller_price) / 2

    def should_walk_away(self, seller_price: float, context: NegotiationContext) -> bool:
        """Walk away after 3 rounds or if seller isn't budging."""
        if context.rounds_completed >= 3:
            return True
        if (context.seller_previous_price is not None
                and seller_price >= context.seller_previous_price):
            return True  # Seller isn't moving
        return False

The NegotiationContext provides:

Field Type Description
rounds_completed int Number of rounds so far
seller_last_price float Seller's most recent price
our_last_offer float? Our most recent counter (None for first round)
seller_previous_price float? Seller's price from the round before last
started_at datetime When the negotiation started

Data Models

NegotiationResult

Returned by auto_negotiate():

class NegotiationResult:
    proposal_id: str
    outcome: NegotiationOutcome  # "accepted", "walked_away", "declined", "error"
    final_price: Optional[float]  # None if walked away
    rounds_count: int
    rounds: list[NegotiationRound]
    completed_at: datetime

NegotiationRound

Each round in the negotiation history:

class NegotiationRound:
    round_number: int
    buyer_price: float       # What we offered
    seller_price: float      # What the seller countered
    action: str              # "counter", "accept", "reject", "final_offer"
    rationale: str           # Seller's explanation (if provided)
    timestamp: datetime

NegotiationSession

Tracks an active negotiation (used in manual mode):

class NegotiationSession:
    proposal_id: str
    seller_url: str
    negotiation_id: str
    current_seller_price: float
    our_last_offer: Optional[float]
    rounds: list[NegotiationRound]
    started_at: datetime

Seller-Side Integration

The buyer's NegotiationClient communicates with the seller's negotiation endpoints:

Action HTTP Call
Counter-offer POST /proposals/{id}/counter with {price: float}
Accept POST /proposals/{id}/counter with {price: float, action: "accept"}
Decline POST /proposals/{id}/counter with {action: "decline"}

The seller's negotiation engine responds with its own strategy (AGGRESSIVE, STANDARD, COLLABORATIVE, or PREMIUM depending on the buyer's tier). See the Seller Negotiation Protocol for the seller-side view.

Negotiation Eligibility

Whether a buyer can negotiate depends on its access tier:

Tier Can Negotiate How
PUBLIC No
SEAT No
AGENCY Yes Reveal agency_id via identity headers
ADVERTISER Yes Reveal advertiser_id via identity headers

Check programmatically:

from ad_buyer.models.buyer_identity import BuyerContext

if buyer_context.can_negotiate():
    result = await client.auto_negotiate(seller_url, proposal_id, strategy)

Roadmap

Planned Improvements

See PROGRESS.md for current status on:

  • AdaptiveStrategy — Dynamic concession based on seller patterns
  • CompetitiveStrategy — Multi-seller competitive bidding
  • Negotiation rules engine — Configure strategies via API instead of code
  • Historical negotiation analytics — Track outcomes across deals
  • Integration into booking flow — Auto-negotiate as a step in deal_booking_flow