Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions prompts/playbooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Haggly Prompt Playbooks

These files are concise source material for the v2 Haggly agent harness.

They are not canned scripts. The API prompt assembler selects the relevant playbooks, combines them with structured negotiation context, and lets the hosted model decide whether to ask questions, recommend a move, or draft wording.

Original research and older car negotiation notes remain in `research/` and `scripts/` as reference material.
17 changes: 17 additions & 0 deletions prompts/playbooks/buyer-car-dealership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Buyer Car Dealership

Use this when the user is buying a vehicle from a dealership.

Buyer priorities:

- Focus on out-the-door price, not monthly payment.
- Keep financing, trade-in, and vehicle price separate until the user chooses otherwise.
- Watch for urgency pressure and manager-check loops.
- Compare the deal against market value and the user's walk-away number.

Useful dealership moves:

- Ask for the full out-the-door price in writing.
- Slow down same-day pressure.
- Treat dealer add-ons and fees as negotiable unless legally required.
- Recommend leaving when the dealer will not clarify total cost.
17 changes: 17 additions & 0 deletions prompts/playbooks/buyer-car-private-seller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Buyer Car Private Seller

Use this when the user is buying a vehicle from an individual owner or private seller.

Buyer priorities:

- Build enough rapport to keep the seller talking.
- Gather condition, maintenance, ownership, and urgency details before offering.
- Use discovered concerns as the reason for price movement.
- Keep the offer calm and tied to specific facts.

Useful private-seller moves:

- Ask about maintenance records, accidents, tires, brakes, title status, and selling reason.
- If concerns appear, name two or three facts and make a grounded offer.
- If the seller is emotionally attached, acknowledge care before discussing price.
- If the seller has urgency, use speed and certainty as leverage.
19 changes: 19 additions & 0 deletions prompts/playbooks/core-negotiation-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## Core Negotiation Agent

Haggly is an agentic negotiation assistant. Diagnose before drafting.

Operating rules:

- Read the leverage before recommending wording.
- Identify missing context instead of guessing.
- Ask one useful follow-up question when the next move is unclear.
- Recommend the next move before writing a message.
- Draft wording only when drafting is the right next step.
- Keep advice practical, calm, and specific to the user's side of the negotiation.

Response shape:

1. Current read
2. Missing context
3. Next move
4. Suggested wording, only if useful
18 changes: 18 additions & 0 deletions prompts/playbooks/seller-marketplace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## Seller Marketplace

Use this when the user is selling an item in a marketplace-style negotiation.

Seller priorities:

- Protect the floor price.
- Avoid over-explaining.
- Test buyer seriousness before making concessions.
- Tie concessions to clean logistics, such as pickup timing or payment certainty.
- If the offer meets the seller's minimum, move toward logistics instead of reopening price.

Useful seller moves:

- For lowball offers, acknowledge once and redirect to a serious range.
- For close offers, counter lightly or accept with clear pickup terms.
- For acceptable offers, confirm the deal and move to scheduling.
- If the buyer points out flaws, avoid defensiveness and restate how pricing already accounts for condition.
130 changes: 130 additions & 0 deletions src/lib/prompts/promptAssembler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')

const PLAYBOOKS = [
{
id: 'core-negotiation-agent',
path: 'prompts/playbooks/core-negotiation-agent.md',
},
{
id: 'seller-marketplace',
path: 'prompts/playbooks/seller-marketplace.md',
},
{
id: 'buyer-car-dealership',
path: 'prompts/playbooks/buyer-car-dealership.md',
},
{
id: 'buyer-car-private-seller',
path: 'prompts/playbooks/buyer-car-private-seller.md',
},
]

const playbookCache = new Map()

function getPlaybookById(id) {
const playbook = PLAYBOOKS.find((candidate) => candidate.id === id)

if (!playbook) {
throw new Error(`Unknown playbook: ${id}`)
}

if (!playbookCache.has(id)) {
playbookCache.set(id, {
...playbook,
content: readFileSync(resolve(repoRoot, playbook.path), 'utf8').trim(),
})
}

return playbookCache.get(id)
}

function includesAny(text, terms) {
return terms.some((term) => text.includes(term))
}

function getSelectionText({ context, latestMessage }) {
return `${context?.item ?? ''} ${latestMessage ?? ''}`.toLowerCase()
}

function getBuyerPlaybookId(selectionText) {
const isDealership = includesAny(selectionText, [
'dealer',
'dealership',
'out-the-door',
'otd',
'manager',
'financing',
])

if (isDealership) {
return 'buyer-car-dealership'
}

const isPrivateCarSale = includesAny(selectionText, [
'car',
'vehicle',
'truck',
'honda',
'civic',
'private seller',
'owner',
])

if (isPrivateCarSale) {
return 'buyer-car-private-seller'
}

return null
}

function formatConversation(conversation) {
if (!conversation?.length) {
return '(none)'
}

return conversation
.map((message) => `${message.role}: ${message.content}`)
.join('\n')
}

export function selectPlaybooks({ context = {}, latestMessage = '' } = {}) {
const selectedIds = ['core-negotiation-agent']

if (context.mode === 'buyer') {
const buyerPlaybookId = getBuyerPlaybookId(getSelectionText({ context, latestMessage }))

if (buyerPlaybookId) {
selectedIds.push(buyerPlaybookId)
}
} else {
selectedIds.push('seller-marketplace')
}

return selectedIds.map(getPlaybookById)
}

export function assembleHagglyPrompt({ context = {}, latestMessage = '', conversation = [] } = {}) {
const playbooks = selectPlaybooks({ context, latestMessage })

return {
playbookIds: playbooks.map((playbook) => playbook.id),
system: [
'You are Haggly, an agentic AI negotiation assistant.',
'Do not behave like a script generator. Diagnose first, ask for missing context when needed, and draft only when drafting is the right next move.',
'Use these playbooks as guidance, not as canned copy.',
playbooks.map((playbook) => playbook.content).join('\n\n'),
].join('\n\n'),
user: [
'Negotiation context JSON:',
JSON.stringify(context, null, 2),
'Latest user message:',
latestMessage || '(none)',
'Conversation history:',
formatConversation(conversation),
].join('\n\n'),
}
}
90 changes: 90 additions & 0 deletions src/lib/prompts/promptAssembler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { buildNegotiationContext } from '../../features/negotiation/negotiationContext'
import { assembleHagglyPrompt, selectPlaybooks } from './promptAssembler'

describe('selectPlaybooks', () => {
it('selects core and seller marketplace guidance for seller context', () => {
const context = buildNegotiationContext({
mode: 'seller',
item: 'gaming chair',
askingPrice: 200,
theirOffer: 80,
})

const playbooks = selectPlaybooks({
context,
latestMessage: 'They said they can pick up today if I take $80.',
})

expect(playbooks.map((playbook) => playbook.id)).toEqual([
'core-negotiation-agent',
'seller-marketplace',
])
})

it('selects dealership guidance for a buyer working with a dealer', () => {
const context = buildNegotiationContext({
mode: 'buyer',
item: 'used Honda Civic',
askingPrice: 18000,
})

const playbooks = selectPlaybooks({
context,
latestMessage: 'I am buying from a dealership and want the out-the-door price.',
})

expect(playbooks.map((playbook) => playbook.id)).toEqual([
'core-negotiation-agent',
'buyer-car-dealership',
])
})

it('selects private-seller car guidance for a buyer negotiating with an owner', () => {
const context = buildNegotiationContext({
mode: 'buyer',
item: 'used truck',
askingPrice: 22000,
})

const playbooks = selectPlaybooks({
context,
latestMessage: 'This is a private seller listing from the owner.',
})

expect(playbooks.map((playbook) => playbook.id)).toEqual([
'core-negotiation-agent',
'buyer-car-private-seller',
])
})
})

describe('assembleHagglyPrompt', () => {
it('assembles a prompt with playbooks and structured context without an API key', () => {
const context = buildNegotiationContext({
mode: 'seller',
item: 'gaming chair',
askingPrice: 200,
theirOffer: 80,
minimumPrice: 160,
})

const prompt = assembleHagglyPrompt({
context,
latestMessage: 'They offered $80 and want to pick up tonight.',
conversation: [
{ role: 'user', content: 'I am selling a gaming chair for $200.' },
{ role: 'assistant', content: 'What is your minimum?' },
],
})

expect(prompt.playbookIds).toEqual(['core-negotiation-agent', 'seller-marketplace'])
expect(prompt.system).toContain('You are Haggly')
expect(prompt.system).toContain('Do not behave like a script generator')
expect(prompt.system).toContain('## Core Negotiation Agent')
expect(prompt.system).toContain('## Seller Marketplace')
expect(prompt.user).toContain('"offerType": "lowball"')
expect(prompt.user).toContain('They offered $80 and want to pick up tonight.')
expect(prompt.user).toContain('What is your minimum?')
})
})